mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
Protect in-progress outgoing captures from quota eviction.
Register active voice/image capture paths in the eviction exclusion set until stop/cancel, and add decisive reservingBytes tests so a 98 MB tree only evicts when headroom is requested.
This commit is contained in:
parent
4911417357
commit
dacc733153
@ -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)
|
||||
|
||||
@ -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_<burstID>.m4a`, not the incoming
|
||||
// `voice_live_` prefix — protect the path until finish/cancel.
|
||||
BLEIncomingFileStore.shared.beginEvictionProtection(for: url)
|
||||
return (url, reservation)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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_<ts>_<uuid>.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 {
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user