diff --git a/bitchat/Features/media/ImageUtils.swift b/bitchat/Features/media/ImageUtils.swift index a28dfdb5..8a4db2f1 100644 --- a/bitchat/Features/media/ImageUtils.swift +++ b/bitchat/Features/media/ImageUtils.swift @@ -77,16 +77,21 @@ enum ImageUtils { let reservation: BLEIncomingFileStore.QuotaByteReservation? if outputDirectory == nil { // Hold headroom only for the default Application Support tree; - // test / custom directories skip quota. Release even if the - // write throws so a failed encode→disk handoff cannot leak. + // test / custom directories skip quota entirely (see + // `makeOutputURL`). Release even if the write throws so a + // failed encode→disk handoff cannot leak. reservation = BLEIncomingFileStore.shared.reserveQuotaBytes( jpegData.count, scope: .outgoing ) + BLEIncomingFileStore.shared.beginEvictionProtection(for: outputURL) } else { reservation = nil } defer { + if outputDirectory == nil { + BLEIncomingFileStore.shared.endEvictionProtection(for: outputURL) + } if let reservation { BLEIncomingFileStore.shared.releaseQuotaReservation(reservation) } @@ -171,14 +176,20 @@ enum ImageUtils { let outputURL = try makeOutputURL(outputDirectory: outputDirectory) let reservation: BLEIncomingFileStore.QuotaByteReservation? if outputDirectory == nil { + // Custom `outputDirectory` (tests) skips quota — only the + // default Application Support tree is budgeted. reservation = BLEIncomingFileStore.shared.reserveQuotaBytes( jpegData.count, scope: .outgoing ) + BLEIncomingFileStore.shared.beginEvictionProtection(for: outputURL) } else { reservation = nil } defer { + if outputDirectory == nil { + BLEIncomingFileStore.shared.endEvictionProtection(for: outputURL) + } if let reservation { BLEIncomingFileStore.shared.releaseQuotaReservation(reservation) } @@ -226,6 +237,13 @@ enum ImageUtils { } #endif + /// Resolves the JPEG destination path. + /// + /// When `outputDirectory` is nil, the file lands in the default + /// Application Support `images/outgoing` tree (callers must + /// reserve/release quota bytes and eviction protection around the + /// write). A non-nil `outputDirectory` is a test/custom escape hatch + /// that skips quota entirely. private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd_HHmmss" @@ -235,9 +253,6 @@ enum ImageUtils { if let outputDirectory { directory = outputDirectory } else { - // Default Application Support outgoing tree shares the process-wide - // store with BLE deletion/delivery so quota exclusions stay live. - // Callers reserve/release quota bytes around the write itself. directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true) } try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes) diff --git a/bitchat/Features/voice/VoiceCaptureSession.swift b/bitchat/Features/voice/VoiceCaptureSession.swift index 6d483767..3543d0d1 100644 --- a/bitchat/Features/voice/VoiceCaptureSession.swift +++ b/bitchat/Features/voice/VoiceCaptureSession.swift @@ -103,6 +103,9 @@ final class PTTLiveVoiceSession: VoiceCaptureSession { /// Held outgoing quota headroom for this capture; released on finish, /// cancel, start failure, and panic cancel. private var quotaReservation: BLEIncomingFileStore.QuotaByteReservation? + /// Path registered so concurrent outgoing eviction cannot delete the + /// file this live session is still writing. + private var protectedCaptureURL: URL? var isLive: Bool { true } @@ -129,6 +132,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession { func start() async throws { let (outputURL, reservation) = try Self.makeOutputURL(burstID: burstID) quotaReservation = reservation + protectedCaptureURL = outputURL let sendPacket = sendPacket let stream = stream capture.onFrames = { frames in @@ -162,7 +166,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession { // handed its token back — nothing to retry. A coordinator-side // interruption during handoff also cancels acquire, but that is // not a successful start and must propagate to the view model. - releaseQuotaReservation() + releaseCaptureGuards() try? FileManager.default.removeItem(at: outputURL) guard completed else { throw CancellationError() } return @@ -177,14 +181,14 @@ final class PTTLiveVoiceSession: VoiceCaptureSession { // user let go, so bail instead of opening a hot mic. guard !completed else { capture.cancel() - releaseQuotaReservation() + releaseCaptureGuards() try? FileManager.default.removeItem(at: outputURL) return } do { try await capture.start(outputURL: outputURL) } catch { - releaseQuotaReservation() + releaseCaptureGuards() try? FileManager.default.removeItem(at: outputURL) throw error } @@ -196,7 +200,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession { func finish() async -> URL? { guard !completed else { return nil } completed = true - defer { releaseQuotaReservation() } + defer { releaseCaptureGuards() } let elapsed = startDate.map { now().timeIntervalSince($0) } ?? 0 let (url, encodedFrames) = capture.stop() @@ -232,7 +236,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession { // pause), and only capture.cancel() stops the mic and deactivates the // session. It is idempotent, so a redundant call is harmless. capture.cancel() - releaseQuotaReservation() + releaseCaptureGuards() if !alreadyCompleted { sendControlPacket(.canceled) } @@ -243,7 +247,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession { // conversation data racing the emergency transport reset. completed = true capture.cancel() - releaseQuotaReservation() + releaseCaptureGuards() } private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) { @@ -251,10 +255,15 @@ final class PTTLiveVoiceSession: VoiceCaptureSession { sendPacket(packet.encode()) } - private func releaseQuotaReservation() { - guard let quotaReservation else { return } - BLEIncomingFileStore.shared.releaseQuotaReservation(quotaReservation) - self.quotaReservation = nil + private func releaseCaptureGuards() { + if let protectedCaptureURL { + BLEIncomingFileStore.shared.endEvictionProtection(for: protectedCaptureURL) + self.protectedCaptureURL = nil + } + if let quotaReservation { + BLEIncomingFileStore.shared.releaseQuotaReservation(quotaReservation) + self.quotaReservation = nil + } } private static func makeOutputURL(burstID: Data) throws -> (URL, BLEIncomingFileStore.QuotaByteReservation) { @@ -282,6 +291,9 @@ final class PTTLiveVoiceSession: VoiceCaptureSession { throw error } let url = directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a") + // Outgoing live notes use `voice_.m4a`, not the incoming + // `voice_live_` prefix — protect the path until finish/cancel. + BLEIncomingFileStore.shared.beginEvictionProtection(for: url) return (url, reservation) } } diff --git a/bitchat/Features/voice/VoiceRecorder.swift b/bitchat/Features/voice/VoiceRecorder.swift index f82f58a5..d0c33a24 100644 --- a/bitchat/Features/voice/VoiceRecorder.swift +++ b/bitchat/Features/voice/VoiceRecorder.swift @@ -76,6 +76,9 @@ actor VoiceRecorder { /// Held outgoing quota headroom for the in-flight capture; released on /// stop, cancel, start failure, and panic cancel. private var quotaReservation: BLEIncomingFileStore.QuotaByteReservation? + /// Path registered with the shared store so concurrent quota eviction + /// cannot delete the file this session is still recording. + private var protectedCaptureURL: URL? init( sessionCoordinator: AudioSessionCoordinator = .shared, @@ -174,7 +177,7 @@ actor VoiceRecorder { return newURL } catch { releaseSessionToken() - releaseQuotaReservation() + releaseCaptureGuards() recorder = nil currentURL = nil activeOwner = nil @@ -193,14 +196,14 @@ actor VoiceRecorder { if startInFlight { activeOwner = nil startInFlight = false - releaseQuotaReservation() + releaseCaptureGuards() return nil } guard let activeRecorder = recorder else { let sessionURL = currentURL releaseSessionToken() - releaseQuotaReservation() + releaseCaptureGuards() currentURL = nil activeOwner = nil return sessionURL @@ -227,7 +230,7 @@ actor VoiceRecorder { activeRecorder.stop() } releaseSessionToken() - releaseQuotaReservation() + releaseCaptureGuards() self.recorder = nil currentURL = nil activeOwner = nil @@ -247,7 +250,7 @@ actor VoiceRecorder { recorder.stop() } releaseSessionToken() - releaseQuotaReservation() + releaseCaptureGuards() if let currentURL { try? FileManager.default.removeItem(at: currentURL) } @@ -308,32 +311,48 @@ actor VoiceRecorder { let fileName = "voice_\(formatter.string(from: Date()))_\(UUID().uuidString).m4a" let baseDirectory: URL + let shouldGuardCapture: Bool if let outputDirectory { baseDirectory = outputDirectory + shouldGuardCapture = false } else { // Reserve worst-case note size for the whole capture. Released on // stop / cancel / start failure so a abandoned hold cannot keep // the outgoing budget permanently tighter. - releaseQuotaReservation() + releaseCaptureGuards() quotaReservation = BLEIncomingFileStore.shared.reserveQuotaBytes( FileTransferLimits.maxVoiceNoteBytes, scope: .outgoing ) baseDirectory = try applicationFilesDirectory() .appendingPathComponent("voicenotes/outgoing", isDirectory: true) + shouldGuardCapture = true } try FileManager.default.createDirectory( at: baseDirectory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes ) - return baseDirectory.appendingPathComponent(fileName) + let url = baseDirectory.appendingPathComponent(fileName) + if shouldGuardCapture { + // `voice__.m4a` is not covered by the live-capture + // prefix; register the path so a concurrent outgoing eviction + // cannot unlink the open recorder file. + BLEIncomingFileStore.shared.beginEvictionProtection(for: url) + protectedCaptureURL = url + } + return url } - private func releaseQuotaReservation() { - guard let quotaReservation else { return } - BLEIncomingFileStore.shared.releaseQuotaReservation(quotaReservation) - self.quotaReservation = nil + private func releaseCaptureGuards() { + if let protectedCaptureURL { + BLEIncomingFileStore.shared.endEvictionProtection(for: protectedCaptureURL) + self.protectedCaptureURL = nil + } + if let quotaReservation { + BLEIncomingFileStore.shared.releaseQuotaReservation(quotaReservation) + self.quotaReservation = nil + } } private func applicationFilesDirectory() throws -> URL { diff --git a/bitchat/Services/BLE/BLEIncomingFileStore.swift b/bitchat/Services/BLE/BLEIncomingFileStore.swift index e4c6ac45..076d8471 100644 --- a/bitchat/Services/BLE/BLEIncomingFileStore.swift +++ b/bitchat/Services/BLE/BLEIncomingFileStore.swift @@ -547,10 +547,26 @@ struct BLEIncomingFileStore: @unchecked Sendable { /// conversation insertion. Before this callback, a deletion transaction /// may not infer ownership from a stale bubble that names the same path. func finishIncomingFileDelivery(at storedURL: URL) { + endEvictionProtection(for: storedURL) + } + + /// Registers `url` in the same exclusion set as pending deliveries so + /// quota eviction / age expiry cannot unlink a file an outgoing capture + /// is still writing (`voice_<…>.m4a` is not covered by `voice_live_`). + /// Pair with `endEvictionProtection` on stop, cancel, or start failure. + func beginEvictionProtection(for url: URL) { + payloadCoordination.lock.lock() + defer { payloadCoordination.lock.unlock() } + payloadCoordination.pendingDeliveryPaths.insert( + url.standardizedFileURL.path + ) + } + + func endEvictionProtection(for url: URL) { payloadCoordination.lock.lock() defer { payloadCoordination.lock.unlock() } payloadCoordination.pendingDeliveryPaths.remove( - storedURL.standardizedFileURL.path + url.standardizedFileURL.path ) } diff --git a/bitchatTests/Services/BLEIncomingFileStoreOutgoingQuotaTests.swift b/bitchatTests/Services/BLEIncomingFileStoreOutgoingQuotaTests.swift index a6ae7481..4cfd958d 100644 --- a/bitchatTests/Services/BLEIncomingFileStoreOutgoingQuotaTests.swift +++ b/bitchatTests/Services/BLEIncomingFileStoreOutgoingQuotaTests.swift @@ -58,6 +58,60 @@ struct BLEIncomingFileStoreOutgoingQuotaTests { #expect(FileManager.default.fileExists(atPath: outgoingNew.path)) } + @Test func outgoingQuotaReservingBytesDecidesEviction() throws { + // 98 MB sits under the 100 MB cap with reservingBytes: 0, but must + // yield when reservingBytes: 10 MB drops the target to 90 MB — so + // the test fails if the reservation argument is ignored. + let (store, root, cleanup) = try makeTempStore() + defer { cleanup() } + + let only = root.appendingPathComponent("files/images/outgoing/only.jpg") + try writeBytes( + 98 * 1024 * 1024, + to: only, + modified: Date(timeIntervalSinceNow: -3600) + ) + + store.enforceOutgoingQuota(reservingBytes: 0) + #expect(FileManager.default.fileExists(atPath: only.path)) + + store.enforceOutgoingQuota(reservingBytes: 10 * 1024 * 1024) + #expect(!FileManager.default.fileExists(atPath: only.path)) + } + + @Test func outgoingQuotaSkipsProtectedInProgressCapture() throws { + // Actively recorded outgoing notes use `voice_<…>.m4a`, not the + // live-capture prefix. Protection must keep them even when they are + // the oldest file and eviction needs the space. + let (store, root, cleanup) = try makeTempStore() + defer { cleanup() } + + let inProgressCapture = root.appendingPathComponent( + "files/voicenotes/outgoing/voice_aabbccddeeff0011.m4a" + ) + let unprotectedNewer = root.appendingPathComponent( + "files/images/outgoing/newer.jpg" + ) + try writeBytes( + 50 * 1024 * 1024, + to: inProgressCapture, + modified: Date(timeIntervalSinceNow: -7200) + ) + try writeBytes( + 55 * 1024 * 1024, + to: unprotectedNewer, + modified: Date(timeIntervalSinceNow: -60) + ) + + store.beginEvictionProtection(for: inProgressCapture) + defer { store.endEvictionProtection(for: inProgressCapture) } + + store.enforceOutgoingQuota(reservingBytes: 10 * 1024 * 1024) + + #expect(FileManager.default.fileExists(atPath: inProgressCapture.path)) + #expect(!FileManager.default.fileExists(atPath: unprotectedNewer.path)) + } + @Test func outgoingQuotaHonorsPendingDeliveryReservationAndLiveCapturePrefix() throws { let (store, root, cleanup) = try makeTempStore() defer { cleanup() } @@ -237,18 +291,19 @@ struct BLEIncomingFileStoreOutgoingQuotaTests { let (store, root, cleanup) = try makeTempStore() defer { cleanup() } - let oldURL = root.appendingPathComponent("files/images/outgoing/old.jpg") - let newURL = root.appendingPathComponent("files/images/outgoing/new.jpg") - try writeBytes(60 * 1024 * 1024, to: oldURL, modified: Date(timeIntervalSinceNow: -3600)) - try writeBytes(45 * 1024 * 1024, to: newURL, modified: Date(timeIntervalSinceNow: -60)) + // 98 MB alone is under quota; a held 10 MB reservation must force + // eviction (target 90 MB). Fails if held bytes are ignored. + let only = root.appendingPathComponent("files/images/outgoing/only.jpg") + try writeBytes( + 98 * 1024 * 1024, + to: only, + modified: Date(timeIntervalSinceNow: -3600) + ) - // Hold 10 MB without an extra reservingBytes argument — eviction must - // still free the oldest file because usage + held exceeds quota. let reservation = store.reserveQuotaBytes(10 * 1024 * 1024, scope: .outgoing) defer { store.releaseQuotaReservation(reservation) } - #expect(!FileManager.default.fileExists(atPath: oldURL.path)) - #expect(FileManager.default.fileExists(atPath: newURL.path)) + #expect(!FileManager.default.fileExists(atPath: only.path)) #expect(store.reservedQuotaBytes(for: .outgoing) == 10 * 1024 * 1024) } }