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:
Ayush7614 2026-07-31 17:47:26 +05:30
parent 4911417357
commit dacc733153
5 changed files with 152 additions and 35 deletions

View File

@ -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 encodedisk handoff cannot leak.
// test / custom directories skip quota entirely (see
// `makeOutputURL`). Release even if the write throws so a
// failed encodedisk 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)

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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
)
}

View File

@ -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)
}
}