feat: live byte progress and best-effort cancel for file-promise drops

Covers two of the file-promise trade-offs (A + B from the review):

A. Progress: poll bytes streamed into the (shared) per-item subdir while
   the opaque receivePromisedFiles runs, feeding the toast's live size
   readout instead of a dead spinner. Best-effort — providers that write
   a temp file and atomically rename only become visible at the end.

B. Cancellation: DropCancellationToken gains an onCancel hook; the
   promise path cancels its operation queue and unblocks the wait loop
   on ⊗ instead of sitting on the 30 s timeout. Cooperative providers
   abort; either way we stop waiting and clean up at once.

C (true determinate % via NSItemProvider.loadFileRepresentation) is
intentionally NOT implemented: macOS AppKit drags don't vend
NSItemProvider, and NSFilePromiseReceiver exposes no Progress/cancel
surface, so a real percentage isn't reachable without an undocumented
hack. The size poller is the honest ceiling.

Also: capture the dispatch group locally in RelocationGate.drain to drop
a Sendable-capture warning. Adds DropCancellationTokenTests.
This commit is contained in:
Dal Rupnik 2026-05-19 10:59:44 +02:00
parent c596672c5a
commit b954c0d56c
3 changed files with 129 additions and 10 deletions

View File

@ -3,9 +3,14 @@ import Foundation
/// Thread-safe cancellation flag for an in-flight hostguest copy. The toast /// Thread-safe cancellation flag for an in-flight hostguest copy. The toast
/// holds one of these and flips it when the user clicks the close button; /// holds one of these and flips it when the user clicks the close button;
/// `DropProgressCopier` polls it between chunks and throws `DropCopyCancelled`. /// `DropProgressCopier` polls it between chunks and throws `DropCopyCancelled`.
///
/// `onCancel` lets callers react to cancellation imperatively used by the
/// file-promise path, whose `receivePromisedFiles` API has no cancellation
/// parameter, to tear down its operation queue and unblock its wait loop.
final class DropCancellationToken { final class DropCancellationToken {
private let lock = NSLock() private let lock = NSLock()
private var _cancelled = false private var _cancelled = false
private var handlers: [() -> Void] = []
var isCancelled: Bool { var isCancelled: Bool {
lock.lock() lock.lock()
@ -15,8 +20,28 @@ final class DropCancellationToken {
func cancel() { func cancel() {
lock.lock() lock.lock()
defer { lock.unlock() } if _cancelled {
lock.unlock()
return
}
_cancelled = true _cancelled = true
let toRun = handlers
handlers = []
lock.unlock()
toRun.forEach { $0() }
}
/// Invoke `handler` as soon as the token is cancelled immediately if it
/// already is. Handlers run once, outside the lock.
func onCancel(_ handler: @escaping () -> Void) {
lock.lock()
if _cancelled {
lock.unlock()
handler()
return
}
handlers.append(handler)
lock.unlock()
} }
} }

View File

@ -32,9 +32,10 @@ final class RelocationGate {
/// Wait up to `timeout` seconds for outstanding relocations. Bridged off /// Wait up to `timeout` seconds for outstanding relocations. Bridged off
/// the caller's actor so it never blocks the main thread. /// the caller's actor so it never blocks the main thread.
func drain(timeout: TimeInterval) async { func drain(timeout: TimeInterval) async {
let group = self.group
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
DispatchQueue.global().async { DispatchQueue.global().async {
_ = self.group.wait(timeout: .now() + timeout) _ = group.wait(timeout: .now() + timeout)
cont.resume() cont.resume()
} }
} }
@ -142,16 +143,23 @@ final class DropHandler {
writtenNames = [displayName] writtenNames = [displayName]
case .promise(let receiver): case .promise(let receiver):
// Promises carry no byte progress; totalBytes 0 indeterminate // The promise API exposes no total size, so the bar stays
// bar while the source app writes straight into the share. // indeterminate; the detail line shows the live byte count we
// poll off the (shared) subdir as the source app streams in.
DispatchQueue.main.async { DispatchQueue.main.async {
DropProgressToast.shared.begin( DropProgressToast.shared.begin(
parent: box.window, filename: displayName, totalBytes: 0, parent: box.window, filename: displayName, totalBytes: 0,
index: idx + 1, count: total, cancelToken: cancelToken, sessionID: sessionID index: idx + 1, count: total, cancelToken: cancelToken, sessionID: sessionID
) )
} }
writtenNames = try receivePromise(receiver, into: subdir, token: cancelToken) writtenNames = try receivePromise(receiver, into: subdir, token: cancelToken) { copied in
.map { $0.lastPathComponent } DispatchQueue.main.async {
DropProgressToast.shared.update(
copied: copied, total: 0,
index: idx + 1, count: total, sessionID: sessionID
)
}
}.map { $0.lastPathComponent }
} }
let waiting = relocationPossible let waiting = relocationPossible
@ -258,18 +266,54 @@ final class DropHandler {
/// exactly once no host-side staging-then-copy. Returns the URLs actually /// exactly once no host-side staging-then-copy. Returns the URLs actually
/// written. Throws `DropCopyCancelled` if the user cancelled, or /// written. Throws `DropCopyCancelled` if the user cancelled, or
/// `DropPromiseFailed` if the provider produced nothing. /// `DropPromiseFailed` if the provider produced nothing.
///
/// `progress` is fed the bytes streamed into `subdir` so far, polled while
/// the receive is in flight the `NSFilePromiseReceiver` API itself
/// reports nothing until each file is fully written. Best-effort: a
/// provider that writes to a temp path and atomically renames into place
/// only becomes visible at the end.
private func receivePromise( private func receivePromise(
_ receiver: NSFilePromiseReceiver, _ receiver: NSFilePromiseReceiver,
into subdir: URL, into subdir: URL,
token: DropCancellationToken token: DropCancellationToken,
progress: @escaping (Int64) -> Void
) throws -> [URL] { ) throws -> [URL] {
let opQueue = OperationQueue() let opQueue = OperationQueue()
let lock = NSLock() let lock = NSLock()
var urls: [URL] = [] var urls: [URL] = []
var firstError: Error? var firstError: Error?
var polling = true
let sem = DispatchSemaphore(value: 0) let sem = DispatchSemaphore(value: 0)
let expected = max(1, receiver.fileNames.count) let expected = max(1, receiver.fileNames.count)
// B: receivePromisedFiles has no cancellation parameter. On , cancel the
// operation queue (cooperative providers honor it) and wake the wait loop
// immediately instead of sitting on the 30 s timeout.
token.onCancel {
opQueue.cancelAllOperations()
for _ in 0..<expected { sem.signal() }
}
// A: poll bytes-on-disk so the toast shows a live, honest size during the
// opaque receive. Stops via `polling` on any exit (the defer below).
let pollQueue = DispatchQueue(label: "org.cirruslabs.tart.dragdrop-promise-poll")
func schedulePoll() {
pollQueue.asyncAfter(deadline: .now() + 0.15) {
lock.lock()
let go = polling
lock.unlock()
guard go else { return }
progress(DropProgressCopier.totalSize(of: subdir))
schedulePoll()
}
}
schedulePoll()
defer {
lock.lock()
polling = false
lock.unlock()
}
receiver.receivePromisedFiles(atDestination: subdir, options: [:], operationQueue: opQueue) { url, error in receiver.receivePromisedFiles(atDestination: subdir, options: [:], operationQueue: opQueue) { url, error in
lock.lock() lock.lock()
if let error = error { if let error = error {
@ -287,11 +331,14 @@ final class DropHandler {
if token.isCancelled { throw DropCopyCancelled() } if token.isCancelled { throw DropCopyCancelled() }
if sem.wait(timeout: .now() + 30) == .timedOut { break } if sem.wait(timeout: .now() + 30) == .timedOut { break }
} }
if token.isCancelled { throw DropCopyCancelled() }
lock.lock() lock.lock()
defer { lock.unlock() } let result = urls
if urls.isEmpty { throw firstError ?? DropPromiseFailed() } let err = firstError
return urls lock.unlock()
if result.isEmpty { throw err ?? DropPromiseFailed() }
return result
} }
} }

View File

@ -0,0 +1,47 @@
import XCTest
@testable import tart
final class DropCancellationTokenTests: XCTestCase {
func testFlagFlips() {
let t = DropCancellationToken()
XCTAssertFalse(t.isCancelled)
t.cancel()
XCTAssertTrue(t.isCancelled)
}
func testOnCancelRunsWhenCancelled() {
let t = DropCancellationToken()
var fired = 0
t.onCancel { fired += 1 }
XCTAssertEqual(fired, 0, "must not fire before cancel")
t.cancel()
XCTAssertEqual(fired, 1)
}
func testOnCancelRunsImmediatelyIfAlreadyCancelled() {
let t = DropCancellationToken()
t.cancel()
var fired = 0
t.onCancel { fired += 1 }
XCTAssertEqual(fired, 1, "late handler must run at once on an already-cancelled token")
}
func testCancelIsIdempotentAndHandlersRunOnce() {
let t = DropCancellationToken()
var fired = 0
t.onCancel { fired += 1 }
t.cancel()
t.cancel()
XCTAssertEqual(fired, 1, "handlers must run exactly once across repeated cancels")
}
func testMultipleHandlersAllRun() {
let t = DropCancellationToken()
var a = false
var b = false
t.onCancel { a = true }
t.onCancel { b = true }
t.cancel()
XCTAssertTrue(a && b)
}
}