fix(apple): make receive-cancel actually cancel the transfer

The Cancel button on an in-progress receive did nothing. CoreRepository
funnelled every core call through one serial DispatchQueue, but `receive`
is a blocking core call that occupies that queue for the whole transfer.
The tapped `cancelTransfer` was enqueued behind the in-flight `receive` on
the same serial queue, so it could never run until `receive` returned —
which it never would, because it was waiting to be cancelled. A deadlock
the button couldn't escape.

The Rust core is explicitly designed for cancel to arrive from another
thread mid-receive (VnidropCore.block_on uses a shared runtime handle for
exactly this). Extracts the two-lane dispatch into a CoreDispatcher: a
serial lane for ordered calls and a separate concurrent lane for
interrupt-style calls, and routes cancel through the latter so the signal
reaches the core and unblocks the receive.

Adds CoreDispatcherTests, including a regression guard that an interrupt
completes while the serial lane is blocked.
This commit is contained in:
2026-07-23 18:30:55 +02:00
parent 3c8267adc5
commit 081b59815c
3 changed files with 103 additions and 13 deletions

View File

@@ -0,0 +1,50 @@
import XCTest
@testable import VniDrop
final class CoreDispatcherTests: XCTestCase {
/// Regression guard for the receive-cancel deadlock: an interrupt-lane call
/// must complete even while the serial lane is occupied by a blocking call.
/// With a single shared queue (the old design) the interrupt would be stuck
/// behind the blocked `receive`, and this would time out.
func testInterruptCompletesWhileSerialLaneIsBlocked() async {
let dispatcher = CoreDispatcher()
let serialEntered = DispatchSemaphore(value: 0)
let releaseSerial = DispatchSemaphore(value: 0)
// Occupy the serial lane with a call that blocks until we release it.
let serialTask = Task {
await dispatcher.run {
serialEntered.signal()
releaseSerial.wait()
}
}
XCTAssertEqual(serialEntered.wait(timeout: .now() + 2), .success, "serial lane never started")
// The interrupt lane must run despite the serial lane being blocked.
let interruptDone = DispatchSemaphore(value: 0)
Task.detached {
_ = await dispatcher.runInterrupt { 42 }
interruptDone.signal()
}
XCTAssertEqual(
interruptDone.wait(timeout: .now() + 2), .success,
"interrupt lane was blocked behind the occupied serial lane")
releaseSerial.signal()
_ = await serialTask.value
}
func testRunPropagatesValuesAndErrors() async {
let dispatcher = CoreDispatcher()
let value = await dispatcher.run { 7 }
XCTAssertEqual(try? value.get(), 7)
let failure = await dispatcher.run { () -> Int in throw TestError.unimplemented }
switch failure {
case .success: XCTFail("expected the thrown error to propagate")
case .failure(let error): XCTAssertTrue(error is TestError)
}
}
}