Created
April 7, 2025 13:50
-
-
Save siracusa/fdc148f4746ff2e8b736b5d602e3c320 to your computer and use it in GitHub Desktop.
Adding a timeout to code like this is tricky…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Assume Swift 6 language mode | |
import Network | |
import Foundation | |
print("Asking for the current time from an NTP server...") | |
do { | |
let now = try await currentTime() | |
print("Current time from NTP: \(now)") | |
} | |
catch { | |
print("Failed to get current time from NTP server: \(error)") | |
} | |
exit(0) | |
// To cause this code to hang, add this to the bottom of /etc/pf.conf | |
// | |
// # /etc/pf.conf | |
// # Define time.apple.com | |
// table <time_apple> { time.apple.com } | |
// | |
// # Block outgoing NTP traffic to time.apple.com | |
// block out proto udp from any to <time_apple> port 123 | |
// block out proto tcp from any to <time_apple> port 123 | |
// | |
// # Block incoming NTP traffic from time.apple.com | |
// block in proto udp from <time_apple> port 123 to any | |
// block in proto tcp from <time_apple> port 123 to any | |
// | |
// Then run this command to enable the new rules: | |
// | |
// sudo pfctl -f /etc/pf.conf && sudo pfctl -e | |
// | |
// To disable the rules temporarily, run: | |
// | |
// sudo pfctl -d | |
// | |
// To revert these changes, delete or comment out the lines added | |
// above and then run the first command again. | |
func currentTime() async throws -> Date { | |
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Date, Error>) in | |
// Some scenarios to consider: | |
// | |
// 1. connection.send() completes successfully, but no response ever | |
// comes. Will the code below hang forever waiting for that response? | |
// And when it's in this state, is there any way to check for Task | |
// cancellation or some other mechanism to honor a timeout imposed | |
// on this code by something outside it? | |
// | |
// 2. If the timeout is "imposed" by calling connection.cancel() when | |
// the timeout arrives, consider this race: | |
// | |
// 1. connection.send() is called from the stateUpdateHandler | |
// when the state is "ready". | |
// 2. The timeout arrives and the thing enforcing the timeout | |
// calls connection.cancel() | |
// 3. The stateUpdateHandler is called with state set to | |
// "cancelled" which calls continuation.resume(throwing: ...) | |
// 4. The completion handler for the connection.send() call in | |
// step 1 is called, and error is set in the call. It calls | |
// continuation.resume(), leading to a fatal double resume() | |
// error. | |
// | |
// Some Sendable shared state (protected by a queue or Mutex) to | |
// track whether the continuation has been resumed already or not | |
// can be used to avoid this, but there must be a better way... | |
let connection = NWConnection(host: NWEndpoint.Host("time.apple.com"), port: 123, using: .udp) | |
let ntpRequest = createNTPRequestPacket() | |
connection.stateUpdateHandler = { state in | |
switch state { | |
case.ready: | |
connection.send(content: ntpRequest, completion: .contentProcessed { error in | |
if let error { | |
continuation.resume(throwing: error) | |
} | |
}) | |
case .cancelled: | |
continuation.resume(throwing: NTPClientError.cancelled) | |
case .waiting(let error), .failed(let error): | |
continuation.resume(throwing: error) | |
case .preparing, .setup: | |
break | |
@unknown default: | |
break | |
} | |
} | |
connection.receiveMessage { data, _, isComplete, error in | |
if let error { | |
continuation.resume(throwing: error) | |
} | |
else if let data, isComplete { | |
if let date = parseNTPResponse(data) { | |
continuation.resume(returning: date) | |
} | |
else { | |
continuation.resume(throwing: NTPClientError.parseError) | |
} | |
} | |
} | |
connection.start(queue: .global()) | |
} | |
} | |
// XXX: Extremely janky, for demo purposes only | |
func createNTPRequestPacket() -> Data { | |
var packet = Data(count: 48) | |
packet[0] = 0b00100011 | |
return packet | |
} | |
// XXX: Extremely janky, for demo purposes only | |
func parseNTPResponse(_ data: Data) -> Date? { | |
guard data.count >= 48 else { return nil } | |
let responseTimeOffset = 40 | |
let secondsSince1900 = | |
TimeInterval(data.withUnsafeBytes { | |
$0.load(fromByteOffset: responseTimeOffset, as: UInt32.self).bigEndian | |
}) | |
let ntpToUnixOffset: TimeInterval = 2208988800 | |
return Date(timeIntervalSince1970: secondsSince1900 - ntpToUnixOffset) | |
} | |
enum NTPClientError : Error { | |
case cancelled | |
case parseError | |
} |
@saagarjha This approach helps a lot because AsyncThrowingStream.Continuation
's finish(…)
method is much kinder than CheckedContinuation
's resume(…)
methods when it comes to handling multiple calls! The async stream finish(…)
method documentation says "Calling this function more than once has no effect" which is much nicer than the fatal error you get when calling CheckedContinuation
's resume(…)
more than once!
(Also, I think you're missing the (of: Date?.self)
parameter to the withThrowingTaskGroup
call.)
[Update: I'm using Swift 6 in Xcode 16.2, but it turns out that Swift 6.1 in Xcode 16.3 makes the (of: Date?.self)
part unnecessary.]
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Here's mine. I was lazy and aimed for a small diff, so this uses an
AsyncStream
.