Files
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

194 lines
6.6 KiB
Swift

// PR 5 — port-conflict probes used by ServerProcess.start() and the
// health-check loop. Mirrors server_manager.py:224-255.
import Foundation
import Darwin
struct HealthProbeResult: Sendable, Equatable {
let ok: Bool
let url: String
let latencyMs: Int
let statusCode: Int?
let errorDescription: String?
}
struct PortConflictResolver: Sendable {
let host: String
let port: Int
private let healthTimeout: TimeInterval = 5
private var healthURL: URL {
AppConfig.httpURL(host: host, port: port, path: "/health")!
}
// MARK: - Sync probes (cheap; called from start() before spawn)
/// `connect()` to host:port; if it succeeds, port is in use. Times out
/// at 1s on connect_refused — fast enough to be sync.
func isPortInUseSync() -> Bool {
let fd = socket(AF_INET, SOCK_STREAM, 0)
guard fd >= 0 else { return false }
defer { close(fd) }
// Non-blocking mode via fcntl (FIONBIO isn't exposed by Swift's Darwin).
let flags = fcntl(fd, F_GETFL, 0)
_ = fcntl(fd, F_SETFL, flags | O_NONBLOCK)
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = in_port_t(port).bigEndian
addr.sin_addr.s_addr = inet_addr(host)
if addr.sin_addr.s_addr == INADDR_NONE {
// Non-literal hosts are not expected after AppConfig normalization.
return false
}
let connectResult = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { ptr in
connect(fd, ptr, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
if connectResult == 0 { return true }
if errno == EISCONN { return true }
if errno == EINPROGRESS || errno == EWOULDBLOCK || errno == EALREADY {
// Wait briefly for the connection to resolve.
var fdset = fd_set()
__darwin_fd_zero(&fdset)
__darwin_fd_set(fd, &fdset)
var tv = timeval(tv_sec: 1, tv_usec: 0)
let sel = select(fd + 1, nil, &fdset, nil, &tv)
if sel <= 0 { return false }
var soError: Int32 = 0
var len = socklen_t(MemoryLayout<Int32>.size)
getsockopt(fd, SOL_SOCKET, SO_ERROR, &soError, &len)
return soError == 0
}
return false // ECONNREFUSED → free
}
/// `lsof -ti :<port> -sTCP:LISTEN`. Sync because it runs at start().
/// Returns nil if lsof isn't available, the port isn't held, or output
/// can't be parsed.
func findOwnerPIDSync() -> pid_t? {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof")
proc.arguments = ["-ti", ":\(port)", "-sTCP:LISTEN"]
let pipe = Pipe()
proc.standardOutput = pipe
proc.standardError = Pipe()
do {
try proc.run()
} catch {
return nil
}
let deadline = Date().addingTimeInterval(2)
while proc.isRunning && Date() < deadline {
usleep(50_000)
}
if proc.isRunning {
proc.terminate()
return nil
}
let data = (try? pipe.fileHandleForReading.readToEnd()) ?? Data()
let text = String(data: data, encoding: .utf8) ?? ""
let line = text.split(separator: "\n").first.map(String.init) ?? ""
return pid_t(line.trimmingCharacters(in: .whitespacesAndNewlines))
}
/// Sync /health probe: 5s timeout, single shot.
func isOMLXOnPortSync() -> Bool {
var req = URLRequest(url: healthURL)
req.timeoutInterval = healthTimeout
req.httpMethod = "GET"
let semaphore = DispatchSemaphore(value: 0)
let result = SendableBox(false)
URLSession.shared.dataTask(with: req) { _, response, _ in
if let http = response as? HTTPURLResponse, http.statusCode == 200 {
result.value = true
}
semaphore.signal()
}.resume()
let waitMs = Int((healthTimeout + 1) * 1000)
_ = semaphore.wait(timeout: .now() + .milliseconds(waitMs))
return result.value
}
// MARK: - Async probes (used by health-check loop)
func probeHealth() async -> HealthProbeResult {
let url = healthURL
let started = Date()
var req = URLRequest(url: healthURL)
req.timeoutInterval = healthTimeout
req.httpMethod = "GET"
req.cachePolicy = .reloadIgnoringLocalCacheData
do {
let (_, response) = try await URLSession.shared.data(for: req)
let latencyMs = Self.latencyMs(since: started)
let statusCode = (response as? HTTPURLResponse)?.statusCode
return HealthProbeResult(
ok: statusCode == 200,
url: url.absoluteString,
latencyMs: latencyMs,
statusCode: statusCode,
errorDescription: nil
)
} catch {
return HealthProbeResult(
ok: false,
url: url.absoluteString,
latencyMs: Self.latencyMs(since: started),
statusCode: nil,
errorDescription: String(describing: error)
)
}
}
// MARK: - Kill external owner
/// SIGTERM, wait up to `timeout` seconds, then SIGKILL.
/// Returns true if the process is gone afterward.
@discardableResult
func killExternal(_ pid: pid_t, timeout: TimeInterval = 5) async -> Bool {
guard kill(pid, SIGTERM) == 0 else { return false }
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if kill(pid, 0) != 0 { return true }
try? await Task.sleep(for: .milliseconds(100))
}
kill(pid, SIGKILL)
try? await Task.sleep(for: .milliseconds(500))
return kill(pid, 0) != 0
}
private static func latencyMs(since started: Date) -> Int {
max(0, Int(Date().timeIntervalSince(started) * 1000))
}
}
// MARK: - Concurrency-safe scratch box for sync URLSession bridging.
private final class SendableBox<T>: @unchecked Sendable {
var value: T
init(_ value: T) { self.value = value }
}
// MARK: - fd_set helpers (stdlib doesn't expose FD_ZERO/FD_SET portably)
private func __darwin_fd_zero(_ set: inout fd_set) {
set = fd_set()
}
private func __darwin_fd_set(_ fd: Int32, _ set: inout fd_set) {
let intOffset = Int(fd / 32)
let bitOffset = fd % 32
let mask: Int32 = 1 << bitOffset
withUnsafeMutablePointer(to: &set.fds_bits) { ptr in
ptr.withMemoryRebound(to: Int32.self, capacity: 32) { arr in
arr[intOffset] |= mask
}
}
}