Make panic wipe deterministic and device-bound (#1431)

* Make panic wipe deterministic and device-bound

* Scope install markers to iOS

* Harden panic recovery and service shutdown

* Invalidate queued BLE ingress during panic

* Harden panic keychain and media cleanup

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jack@deck.local>
This commit is contained in:
jack
2026-07-26 10:28:50 +02:00
committed by GitHub
co-authored by jack jack
parent fb8fe39713
commit cd727c6867
30 changed files with 2747 additions and 299 deletions
@@ -72,15 +72,98 @@ extension ChatViewModel: ChatMediaTransferContext {
}
}
/// Synchronous boundary between detached image writers and panic deletion.
///
/// Invalidation closes admission before waiting for writers that already
/// entered. Those writers never need the main actor while inside the boundary,
/// so a synchronous panic transaction can safely join them and then delete
/// every output before reporting completion.
private final class ImagePreparationBarrier: @unchecked Sendable {
private let condition = NSCondition()
private var generation: UInt64 = 0
private var activeOperations = 0
var currentGeneration: UInt64 {
condition.lock()
defer { condition.unlock() }
return generation
}
func isCurrent(_ candidate: UInt64) -> Bool {
condition.lock()
defer { condition.unlock() }
return generation == candidate
}
func performIfCurrent<T>(
generation candidate: UInt64,
operation: () throws -> T
) rethrows -> T? {
condition.lock()
guard generation == candidate else {
condition.unlock()
return nil
}
activeOperations += 1
condition.unlock()
defer {
condition.lock()
activeOperations -= 1
if activeOperations == 0 {
condition.broadcast()
}
condition.unlock()
}
return try operation()
}
func invalidateAndWait() {
condition.lock()
generation &+= 1
while activeOperations > 0 {
condition.wait()
}
condition.unlock()
}
}
/// Runs synchronous media encoding and file I/O on a dispatch worker.
///
/// These operations can legitimately block. Keeping them off Swift's
/// cooperative executor preserves forward progress when several transfers or
/// test doubles wait at the same time.
private func runBlockingMediaPreparation<T>(
_ operation: @escaping @Sendable () throws -> T
) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
continuation.resume(returning: try operation())
} catch {
continuation.resume(throwing: error)
}
}
}
}
@MainActor
final class ChatMediaTransferCoordinator {
private unowned let context: any ChatMediaTransferContext
private let prepareImagePacket: @Sendable (URL) throws -> ChatPreparedImage
private let imagePreparationBarrier = ImagePreparationBarrier()
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
private(set) var messageIDToTransferId: [String: String] = [:]
init(context: any ChatMediaTransferContext) {
init(
context: any ChatMediaTransferContext,
prepareImagePacket: @escaping @Sendable (URL) throws -> ChatPreparedImage = {
try ChatMediaPreparation.prepareImagePacket(from: $0)
}
) {
self.context = context
self.prepareImagePacket = prepareImagePacket
}
func sendVoiceNote(at url: URL) {
@@ -98,13 +181,19 @@ final class ChatMediaTransferCoordinator {
)
let messageID = message.id
let transferId = makeTransferID(messageID: messageID)
let generation = imagePreparationBarrier.currentGeneration
Task.detached(priority: .userInitiated) { [weak self] in
do {
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
let packet = try await runBlockingMediaPreparation {
try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
}
await MainActor.run { [weak self] in
guard let self else { return }
guard let self,
self.imagePreparationBarrier.isCurrent(generation) else {
return
}
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
@@ -116,13 +205,19 @@ final class ChatMediaTransferCoordinator {
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
try? FileManager.default.removeItem(at: url)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self,
self.imagePreparationBarrier.isCurrent(generation) else {
return
}
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
}
} catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self,
self.imagePreparationBarrier.isCurrent(generation) else {
return
}
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
}
}
@@ -132,11 +227,27 @@ final class ChatMediaTransferCoordinator {
#if os(iOS)
func processThenSendImage(_ image: UIImage?) {
guard let image else { return }
Task.detached { [weak self] in
let generation = imagePreparationBarrier.currentGeneration
let barrier = imagePreparationBarrier
Task.detached(priority: .userInitiated) { [weak self, barrier] in
do {
let processedURL = try ImageUtils.processImage(image)
await MainActor.run { [weak self] in
guard let self else { return }
let processedURL = try await runBlockingMediaPreparation {
try barrier.performIfCurrent(
generation: generation,
operation: {
try ImageUtils.processImage(image)
}
)
}
guard let processedURL else {
return
}
await MainActor.run { [weak self, barrier] in
guard let self,
barrier.isCurrent(generation) else {
try? FileManager.default.removeItem(at: processedURL)
return
}
self.sendImage(from: processedURL)
}
} catch {
@@ -147,11 +258,27 @@ final class ChatMediaTransferCoordinator {
#elseif os(macOS)
func processThenSendImage(from url: URL?) {
guard let url else { return }
Task.detached { [weak self] in
let generation = imagePreparationBarrier.currentGeneration
let barrier = imagePreparationBarrier
Task.detached(priority: .userInitiated) { [weak self, barrier] in
do {
let processedURL = try ImageUtils.processImage(at: url)
await MainActor.run { [weak self] in
guard let self else { return }
let processedURL = try await runBlockingMediaPreparation {
try barrier.performIfCurrent(
generation: generation,
operation: {
try ImageUtils.processImage(at: url)
}
)
}
guard let processedURL else {
return
}
await MainActor.run { [weak self, barrier] in
guard let self,
barrier.isCurrent(generation) else {
try? FileManager.default.removeItem(at: processedURL)
return
}
self.sendImage(from: processedURL)
}
} catch {
@@ -170,6 +297,7 @@ final class ChatMediaTransferCoordinator {
}
let targetPeer = context.selectedPrivateChatPeer
let generation = imagePreparationBarrier.currentGeneration
do {
try ImageUtils.validateImageSource(at: sourceURL)
@@ -179,12 +307,28 @@ final class ChatMediaTransferCoordinator {
return
}
Task.detached(priority: .userInitiated) { [weak self] in
let prepareImagePacket = self.prepareImagePacket
let barrier = imagePreparationBarrier
Task.detached(priority: .userInitiated) { [weak self, barrier] in
do {
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
let prepared = try await runBlockingMediaPreparation {
try barrier.performIfCurrent(
generation: generation,
operation: {
try prepareImagePacket(sourceURL)
}
)
}
guard let prepared else {
return
}
await MainActor.run { [weak self] in
guard let self else { return }
await MainActor.run { [weak self, barrier] in
guard let self,
barrier.isCurrent(generation) else {
try? FileManager.default.removeItem(at: prepared.outputURL)
return
}
let message = self.enqueueMediaMessage(
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
targetPeer: targetPeer
@@ -200,14 +344,20 @@ final class ChatMediaTransferCoordinator {
}
} catch ChatMediaPreparationError.imageTooLarge(let size) {
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
await MainActor.run { [weak self, barrier] in
guard let self,
barrier.isCurrent(generation) else {
return
}
self.context.addSystemMessage("Image is too large to send.")
}
} catch {
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
await MainActor.run { [weak self, barrier] in
guard let self,
barrier.isCurrent(generation) else {
return
}
self.context.addSystemMessage("Failed to prepare image for sending.")
}
}
@@ -341,6 +491,20 @@ final class ChatMediaTransferCoordinator {
clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true)
}
/// Invalidates detached preparation work and cancels every transfer that
/// reached the transport. Closing image-preparation admission and joining
/// active synchronous writers ensures the following panic media deletion
/// is the last filesystem mutation before the transaction can complete.
func resetForPanic() {
imagePreparationBarrier.invalidateAndWait()
let transferIDs = Set(transferIdToMessageIDs.keys)
transferIdToMessageIDs.removeAll(keepingCapacity: false)
messageIDToTransferId.removeAll(keepingCapacity: false)
for transferID in transferIDs {
context.cancelTransfer(transferID)
}
}
}
private extension ChatMediaTransferCoordinator {