Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Stop cursor-reconcile retries and mailbox timeout waits when the share task is cancelled, so teardown does not stall.

## 0.3.1 - 2026-08-28

### Highlights
Expand Down
13 changes: 12 additions & 1 deletion macos/CrabfleetMac/Sources/CrabfleetMac/MacScreenCapture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,14 @@ final class MacScreenCapture: NSObject, @unchecked Sendable {
try await self.reconcileCursorConfiguration()
break
} catch {
try? await Task.sleep(for: delay)
guard Self.isRetryableCursorReconcileError(error) else { break }
do {
try await Task.sleep(for: delay)
} catch is CancellationError {
break
} catch {
break
}
delay = min(delay * 2, .seconds(5))
}
}
Expand All @@ -434,6 +441,10 @@ final class MacScreenCapture: NSObject, @unchecked Sendable {
_ = task
}

static func isRetryableCursorReconcileError(_ error: Error) -> Bool {
!(error is CancellationError)
}

private func reconcileCursorConfiguration() async throws {
try await configurationGate.run { [self] in
guard let stream, let configuration else { return }
Expand Down
63 changes: 49 additions & 14 deletions macos/CrabfleetMac/Sources/CrabfleetMac/VideoMailbox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ final class VideoMailbox<Element>: @unchecked Sendable {
private let lock = NSLock()
private var latestElement: Element?
private var waiter: Waiter?
private var timeoutTask: Task<Void, Never>?
private var finished = false

var isFinished: Bool {
Expand All @@ -30,27 +31,36 @@ final class VideoMailbox<Element>: @unchecked Sendable {
}

func offer(_ element: Element, onDrop: () -> Void = {}) {
let continuation = withLock { () -> CheckedContinuation<Element?, Never>? in
guard !finished else { return nil }
let (continuation, timeoutTask) = withLock {
() -> (CheckedContinuation<Element?, Never>?, Task<Void, Never>?) in
guard !finished else { return (nil, nil) }
guard let waiter else {
if latestElement != nil { onDrop() }
latestElement = element
return nil
return (nil, nil)
}
self.waiter = nil
return waiter.continuation
let timeoutTask = self.timeoutTask
self.timeoutTask = nil
return (waiter.continuation, timeoutTask)
}
timeoutTask?.cancel()
continuation?.resume(returning: element)
}

func finish() {
let continuation = withLock { () -> CheckedContinuation<Element?, Never>? in
guard !finished else { return nil }
let (continuation, timeoutTask) = withLock {
() -> (CheckedContinuation<Element?, Never>?, Task<Void, Never>?) in
guard !finished else { return (nil, nil) }
finished = true
latestElement = nil
defer { waiter = nil }
return waiter?.continuation
let continuation = waiter?.continuation
let timeoutTask = self.timeoutTask
waiter = nil
self.timeoutTask = nil
return (continuation, timeoutTask)
}
timeoutTask?.cancel()
continuation?.resume(returning: nil)
}

Expand All @@ -64,6 +74,7 @@ final class VideoMailbox<Element>: @unchecked Sendable {
var immediateElement: Element?
var shouldResume = false
var replacedWaiter: Waiter?
var replacedTimeout: Task<Void, Never>?
lock.lock()
if let latestElement {
immediateElement = latestElement
Expand All @@ -73,16 +84,35 @@ final class VideoMailbox<Element>: @unchecked Sendable {
shouldResume = true
} else {
replacedWaiter = waiter
replacedTimeout = timeoutTask
timeoutTask = nil
waiter = (id, continuation)
}
lock.unlock()

replacedWaiter?.continuation.resume(returning: nil)
replacedTimeout?.cancel()
if shouldResume {
continuation.resume(returning: immediateElement)
} else {
Task {
try? await Task.sleep(for: timeout)
let task = Task {
do {
try await Task.sleep(for: timeout)
self.expire(id: id)
} catch is CancellationError {
// Do not expire a different waiter; expire already guards by id.
} catch {
self.expire(id: id)
}
}
let shouldCancelTimeout = withLock { () -> Bool in
guard waiter?.id == id else { return true }
timeoutTask = task
return false
}
if shouldCancelTimeout {
task.cancel()
} else if Task.isCancelled {
self.expire(id: id)
}
}
Expand All @@ -93,11 +123,16 @@ final class VideoMailbox<Element>: @unchecked Sendable {
}

private func expire(id: UUID) {
let continuation = withLock { () -> CheckedContinuation<Element?, Never>? in
guard waiter?.id == id else { return nil }
defer { waiter = nil }
return waiter?.continuation
let (continuation, timeoutTask) = withLock {
() -> (CheckedContinuation<Element?, Never>?, Task<Void, Never>?) in
guard waiter?.id == id else { return (nil, nil) }
let continuation = waiter?.continuation
let timeoutTask = self.timeoutTask
waiter = nil
self.timeoutTask = nil
return (continuation, timeoutTask)
}
timeoutTask?.cancel()
continuation?.resume(returning: nil)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,30 @@ struct VideoPipelineTests {
#expect(await mailbox.next(timeout: .milliseconds(10)) == nil)
}

@Test
func mailboxCancelledWaiterReturnsPromptlyWithoutResumingTwice() async {
let mailbox = VideoMailbox<Int>()
let startedAt = ContinuousClock().now
let task = Task {
withUnsafeCurrentTask { $0?.cancel() }
return await mailbox.next(timeout: .seconds(5))
}
let result = await task.value
#expect(result == nil)
#expect(ContinuousClock().now - startedAt < .milliseconds(500))

mailbox.offer(4)
#expect(await mailbox.next(timeout: .milliseconds(50)) == 4)
}

@Test
func cursorReconcileErrorsAreNotRetryableWhenCancelled() {
#expect(!MacScreenCapture.isRetryableCursorReconcileError(CancellationError()))
#expect(
MacScreenCapture.isRetryableCursorReconcileError(
NSError(domain: "CrabfleetMacTests", code: 1)))
}

@Test
func videoNegotiationPrefersHEVCThenH264ThenTight() {
let offered = [
Expand Down