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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ A modern, reliable network reachability library for iOS with both Swift and Obje

## Features

- **Hybrid Approach**: Combines NWPathMonitor, HTTP HEAD, and ICMP Ping for accurate reachability detection
- **Hybrid Approach**: Combines NWPathMonitor, HTTP HEAD, and real ICMP Ping for accurate reachability detection
- **Dual Target Support**:
- Swift version (iOS 13+) with async/await API
- Objective-C version (iOS 12+) with notification-based API
- **Configurable**: Choose between parallel, HTTP-only, or ICMP-only probe modes
- **True Reachability**: Verifies actual internet connectivity, not just network presence
- **Real ICMP Ping**: Uses actual ICMP echo request/reply (based on Apple's SimplePing) instead of TCP fallback
Comment on lines +7 to +13
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR title/description states this is a macOS compilation fix for PingFoundation.swift, but the diff also introduces a new Objective-C ICMP implementation (RRPingFoundation/RRPingHelper) and replaces the previous TCP-based probe logic. Please update the PR description/title to reflect the functional change, or split the compilation fix into a separate PR to keep review scope manageable.

Copilot uses AI. Check for mistakes.

## Architecture

Expand All @@ -25,6 +26,7 @@ A modern, reliable network reachability library for iOS with both Swift and Obje
┌───────────────────┐ ┌───────────────────┐
│ HTTP HEAD │ │ ICMP Ping │
│ captive.apple │ │ 8.8.8.8 │
│ │ │ (Real ICMP) │
└───────────────────┘ └───────────────────┘
│ │
└──────────────┬──────────────────────┘
Expand Down Expand Up @@ -98,8 +100,7 @@ RealReachability.shared.configuration = ReachabilityConfiguration(
probeMode: .httpOnly, // .parallel, .httpOnly, or .icmpOnly
timeout: 5.0,
httpProbeURL: URL(string: "https://captive.apple.com/hotspot-detect.html")!,
icmpHost: "8.8.8.8",
icmpPort: 53
icmpHost: "8.8.8.8" // Host for ICMP ping
)
```

Expand Down Expand Up @@ -153,13 +154,13 @@ RealReachability.shared.configuration = ReachabilityConfiguration(
|------|-------------|
| `.parallel` (default) | Uses both HTTP HEAD and ICMP in parallel, succeeds if either succeeds |
| `.httpOnly` | Uses only HTTP HEAD request to Apple's captive portal |
| `.icmpOnly` | Uses only TCP connection check (ICMP fallback) |
| `.icmpOnly` | Uses real ICMP echo request/reply |

## Components

- **NWPathMonitor**: System-level network status changes (fast notification)
- **HTTP HEAD**: Checks connectivity to Apple's captive portal (most reliable)
- **ICMP Ping**: TCP connection check to Google DNS (fallback)
- **ICMP Ping**: Real ICMP echo request/reply to Google DNS (based on Apple's SimplePing)

## Requirements

Expand Down
158 changes: 92 additions & 66 deletions Sources/RealReachability2/Prober/ICMPPinger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,21 @@
//

import Foundation
import Network

/// ICMP Ping prober for verifying internet connectivity
/// Note: On iOS, true ICMP requires special entitlements.
/// This implementation uses a TCP connection check as a fallback.
/// Uses real ICMP echo request/reply for accurate network reachability testing.
@available(iOS 13.0, *)
public final class ICMPPinger: Prober, @unchecked Sendable {
/// Default host for ping (Google DNS)
public static let defaultHost = "8.8.8.8"

/// Default port for TCP check
/// Default port (kept for API compatibility, not used for real ICMP)
public static let defaultPort: UInt16 = 53

/// The host to ping
private let host: String

/// The port for TCP check
/// The port (kept for API compatibility, not used for real ICMP)
private let port: UInt16

/// Timeout interval
Expand All @@ -31,7 +29,7 @@ public final class ICMPPinger: Prober, @unchecked Sendable {
/// Creates a new ICMP pinger
/// - Parameters:
/// - host: The host to ping (default: 8.8.8.8)
/// - port: The port for TCP check (default: 53)
/// - port: Kept for API compatibility (not used for real ICMP ping)
/// - timeout: Timeout interval in seconds (default: 5)
public init(host: String = ICMPPinger.defaultHost,
port: UInt16 = ICMPPinger.defaultPort,
Expand All @@ -41,69 +39,15 @@ public final class ICMPPinger: Prober, @unchecked Sendable {
self.timeout = timeout
}

/// Probes the network using TCP connection
/// Probes the network using real ICMP ping
/// - Returns: `true` if the probe was successful
public func probe() async -> Bool {
return await withCheckedContinuation { continuation in
let connection = NWConnection(
host: NWEndpoint.Host(host),
port: NWEndpoint.Port(rawValue: port)!,
using: .tcp
)

// Use a class to hold the resumed state atomically
final class ResumeState {
private let lock = NSLock()
private var _isResumed = false

func tryResume() -> Bool {
lock.lock()
defer { lock.unlock() }
if _isResumed {
return false
}
_isResumed = true
return true
}
await withCheckedContinuation { continuation in
// Use a dedicated class to manage the ping operation
let pingOperation = PingOperation(host: host, timeout: timeout)
pingOperation.ping { success in
continuation.resume(returning: success)
Comment on lines +45 to +49
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PingOperation is created as a local inside withCheckedContinuation and is not retained after the closure returns. Because PingFoundation.delegate is weak and the Timer closure captures self weakly, the operation can be deallocated immediately, which can leave the continuation never resumed (hung probe). Retain the operation until completion (e.g., store it on ICMPPinger/a shared set, or intentionally self-retain during the ping and break the cycle in finishWithResult).

Copilot uses AI. Check for mistakes.
}

let resumeState = ResumeState()

// Set up timeout
let timeoutWorkItem = DispatchWorkItem { [weak connection] in
if resumeState.tryResume() {
connection?.cancel()
continuation.resume(returning: false)
}
}

DispatchQueue.global().asyncAfter(
deadline: .now() + timeout,
execute: timeoutWorkItem
)

connection.stateUpdateHandler = { [weak connection] state in
switch state {
case .ready:
if resumeState.tryResume() {
timeoutWorkItem.cancel()
connection?.cancel()
continuation.resume(returning: true)
}

case .failed, .cancelled:
if resumeState.tryResume() {
timeoutWorkItem.cancel()
connection?.cancel()
continuation.resume(returning: false)
}

default:
break
}
}

connection.start(queue: .global())
}
}

Expand All @@ -116,3 +60,85 @@ public final class ICMPPinger: Prober, @unchecked Sendable {
return ProbeResult(success: success, latencyMs: latency, error: nil)
}
}

// MARK: - Ping Operation

/// Internal class to manage a single ping operation with RunLoop
@available(iOS 13.0, *)
private final class PingOperation: NSObject, PingFoundationDelegate {
private let host: String
private let timeout: TimeInterval
private var pingFoundation: PingFoundation?
private var completion: ((Bool) -> Void)?
private var hasCompleted = false
private var timeoutTimer: Timer?
private let lock = NSLock()

init(host: String, timeout: TimeInterval) {
self.host = host
self.timeout = timeout
super.init()
}

func ping(completion: @escaping (Bool) -> Void) {
self.completion = completion

// Run on main thread for RunLoop integration
if Thread.isMainThread {
startPing()
} else {
DispatchQueue.main.async { [weak self] in
self?.startPing()
}
}
}

private func startPing() {
pingFoundation = PingFoundation(hostName: host)
pingFoundation?.delegate = self
pingFoundation?.start()

// Setup timeout
timeoutTimer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in
self?.finishWithResult(false)
}
}

private func finishWithResult(_ success: Bool) {
lock.lock()
guard !hasCompleted else {
lock.unlock()
return
}
hasCompleted = true
lock.unlock()

DispatchQueue.main.async { [weak self] in
self?.timeoutTimer?.invalidate()
self?.timeoutTimer = nil
self?.pingFoundation?.stop()
self?.pingFoundation = nil
self?.completion?(success)
self?.completion = nil
}
}

// MARK: - PingFoundationDelegate

func pingFoundation(_ pinger: PingFoundation, didStartWithAddress address: Data) {
// Send ping immediately when started
pinger.sendPing(with: nil)
}

func pingFoundation(_ pinger: PingFoundation, didFailWithError error: Error) {
finishWithResult(false)
}

func pingFoundation(_ pinger: PingFoundation, didFailToSendPacket packet: Data, sequenceNumber: UInt16, error: Error) {
finishWithResult(false)
}

func pingFoundation(_ pinger: PingFoundation, didReceivePingResponsePacket packet: Data, sequenceNumber: UInt16) {
finishWithResult(true)
}
}
Loading