Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ All notable changes to this project are documented here. This project follows Se
Unreleased
----------

### Added

- `cancelActiveTraces()` cancels currently active traces without invalidating cached rDNS,
public IP, or ASN resolution.
- `TraceOptions` and per-operation options on `trace(to:options:)` and `traceClassified(to:vpnContext:resolver:options:)`
permit overriding `maxHops` on individual traces without reconfiguring the actor.

### Tooling

- Pull requests are now gated on the DocC documentation build: broken doc links and
Expand Down
11 changes: 11 additions & 0 deletions Sources/SwiftFTR/ConfigurationValidation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,17 @@ extension StreamingTraceConfig {
}
}

extension TraceOptions {
/// Validates per-operation options before socket operations begin.
func validateForOperation() throws {
if let maxHops {
guard (1...255).contains(maxHops) else {
throw TracerouteError.invalidConfiguration(reason: "maxHops must be in 1...255")
}
}
}
}

extension PingConfig {
/// Validates values before constructing packets, sequence numbers, sleeps, or timers.
func validateForOperation() throws {
Expand Down
125 changes: 123 additions & 2 deletions Sources/SwiftFTR/TraceHandle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Foundation
public actor TraceHandle {
private var _isCancelled = false
private var cancellationHandler: (@Sendable () -> Void)?
private var handlerRegistrationID: UInt64 = 0

/// Whether this trace has been cancelled.
public var isCancelled: Bool {
Expand All @@ -31,16 +32,29 @@ public actor TraceHandle {
///
/// If cancellation already happened, the handler is invoked immediately so
/// setup cannot race ahead with an already-cancelled trace.
internal func installCancellationHandler(_ handler: @escaping @Sendable () -> Void) {
///
/// - Returns: A monotonically increasing registration ID identifying this handler.
@discardableResult
internal func installCancellationHandler(_ handler: @escaping @Sendable () -> Void) -> UInt64 {
handlerRegistrationID &+= 1
let id = handlerRegistrationID
if _isCancelled {
handler()
} else {
cancellationHandler = handler
}
return id
}

/// Removes an operation-specific cleanup handler after the operation ends.
internal func clearCancellationHandler() {
///
/// If `id` is provided, the handler is only cleared if it matches the specified
/// registration ID. This prevents an asynchronous or delayed cleanup from clearing
/// a subsequent phase's newly installed cancellation handler.
internal func clearCancellationHandler(id: UInt64? = nil) {
if let id {
guard id == handlerRegistrationID else { return }
}
cancellationHandler = nil
}

Expand All @@ -62,3 +76,110 @@ extension TraceHandle: Hashable {
ObjectIdentifier(self).hash(into: &hasher)
}
}

/// A thread-safe box that synchronizes task completion with cancellation.
private final class CancellationContinuationBox<T: Sendable>: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<T, Error>?
private var isCancelled = false
private var hasResumed = false

func cancel() {
lock.lock()
guard !hasResumed else {
lock.unlock()
return
}
isCancelled = true
if let cont = continuation {
continuation = nil
hasResumed = true
lock.unlock()
cont.resume(throwing: TracerouteError.cancelled)
} else {
lock.unlock()
}
}

func attach(continuation: CheckedContinuation<T, Error>, task: Task<T, Error>) {
lock.lock()
if isCancelled {
hasResumed = true
lock.unlock()
continuation.resume(throwing: TracerouteError.cancelled)
return
}
self.continuation = continuation
lock.unlock()

Task {
do {
let value = try await task.value
self.resume(with: .success(value))
} catch {
self.resume(with: .failure(error))
}
}
}

private func resume(with result: Result<T, Error>) {
lock.lock()
guard !hasResumed else {
lock.unlock()
return
}
hasResumed = true
let cont = continuation
continuation = nil
lock.unlock()
cont?.resume(with: result)
}
}

/// Executes an async throwing operation wrapped in an interruptible cancellation race with a `TraceHandle`.
///
/// If `handle` is cancelled (e.g. via `cancelActiveTraces()`) or the calling task is cancelled while the
/// operation is in flight, the operation task is cancelled and this function immediately throws
/// `TracerouteError.cancelled` without stranding the caller.
internal func withTraceCancellation<T: Sendable>(
handle: TraceHandle?,
_ operation: @escaping @Sendable () async throws -> T
) async throws -> T {
guard let handle else {
return try await operation()
}
if await handle.isCancelled {
throw TracerouteError.cancelled
}

let task = Task {
try await operation()
}

let box = CancellationContinuationBox<T>()
let registrationID = await handle.installCancellationHandler {
task.cancel()
box.cancel()
}

do {
let result = try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<T, Error>) in
box.attach(continuation: continuation, task: task)
}
} onCancel: {
task.cancel()
box.cancel()
}
await handle.clearCancellationHandler(id: registrationID)
return result
} catch {
await handle.clearCancellationHandler(id: registrationID)
task.cancel()
box.cancel()
if await handle.isCancelled || error is CancellationError {
throw TracerouteError.cancelled
}
throw error
}
}
Loading