T-iOS-32's acceptance is "builder 测试 + 端到端一次". The builder half was green, but grep for projects/worktree across the whole test tree returned nothing — no test had ever created or removed a real git worktree against a real server. Six tests on a throwaway git fixture: create/remove/prune verified against both the filesystem and git's own `worktree list --porcelain` + .git/worktrees/, and asserting the values the SERVER derives rather than echoing our input (branch feature/e2e-worktree becomes directory feature-e2e-worktree; the raw branch name must never appear in the path). Includes a differential leg for the guarded-route rule — the same request without Origin is 403 with zero side effects, and with it is 200 — which needs raw URLSession, since APIClient structurally cannot emit an Origin-less guarded request. GET /sessions gets its own HOME-isolated server: history.ts reads os.homedir()/.claude/projects, so against the shared harness the assertions would land on the developer's real Claude history, and in CI the directory is absent so it degrades to [] and proves nothing. Integration tests 26 -> 32, independently re-run.
426 lines
21 KiB
Swift
426 lines
21 KiB
Swift
//
|
||
// ServerHarness.swift — T-iOS-16 集成测试基础设施:真 Node 服务器自举
|
||
//
|
||
// 由 T-iOS-2 的 OriginSpikeTests.swift(591 行单文件)拆分而来(plan §7 T-iOS-16
|
||
// 明确批准 harness + suites 拆分)。本文件只管服务器生命周期:
|
||
// - spawn 一次本仓库真服务器(`node_modules/.bin/tsx src/server.ts`),全部
|
||
// suite 复用同一进程(会话彼此独立,测试并行安全);
|
||
// - 就绪探测(GET /live-sessions 返回 JSON 数组);
|
||
// - 进程回收:death-pipe watchdog + atexit 双保险(Swift Testing runner 退出
|
||
// 时不可靠地跑 atexit,watchdog 是主机制)。
|
||
//
|
||
// 运行方式(可复现):
|
||
// A. 默认自举:`swift test --package-path ios/IntegrationTests`
|
||
// repo 根由 #filePath 推导(可用 WEBTERM_REPO_ROOT 覆盖),绑定 127.0.0.1
|
||
// 的空闲端口,SHELL_PATH=/bin/bash(确定性输出)、USE_TMUX=0。无需
|
||
// ALLOWED_ORIGINS:服务器的 deriveAllowedOrigins 恒包含
|
||
// http://127.0.0.1:<port>(src/config.ts:187-226)。
|
||
// 日志落在 $TMPDIR/webterm-integration-server-<port>.log。
|
||
// B. 外部服务器:WEBTERM_SERVER_URL=http://127.0.0.1:<port>(须为 loopback,
|
||
// 这样其自身 origin 天然在白名单内)。
|
||
//
|
||
// F3 追加:**第二台**服务器 —— 开了 `WEBTERM_TOKEN` 的令牌网关服务器
|
||
// (`ServerHarness.tokenGatedServer()`)。两台并存是必需的:`POST /auth` 的
|
||
// 「204 但没有 Set-Cookie」只在**未启用**鉴权的服务器上才出现,而 401/终态用例
|
||
// 只在**启用**的服务器上才出现,同一进程无法同时是两者(令牌只在启动时读一次)。
|
||
// A. 默认自举:随机生成一枚符合 §1.1 字符集/长度的**一次性**令牌,经 env
|
||
// 传给子进程(与真实部署同一通路),仅存在于本进程内存;绝不写日志、
|
||
// 绝不进 URL、绝不出现在断言文案里。
|
||
// B. 外部服务器:WEBTERM_TOKEN_SERVER_URL + WEBTERM_TOKEN_SERVER_TOKEN
|
||
// (worktree 无 node_modules 时用:从主 checkout 手工起一台带令牌的服务器)。
|
||
//
|
||
// 与 spike 版的关键差异:WireProtocol 已冻结(T-iOS-3),Origin/wsURL 一律由
|
||
// `HostEndpoint` 单点派生(plan §5.1 铁律:禁止手拼),不再本地复刻常量。
|
||
|
||
import Darwin
|
||
import Foundation
|
||
import WireProtocol
|
||
#if canImport(FoundationNetworking)
|
||
import FoundationNetworking
|
||
#endif
|
||
|
||
// ─── 常量(无魔法数字;线协议常量一律取 WireProtocol.Tunables/WireConstants) ──
|
||
|
||
enum HarnessTunables {
|
||
/// 灌入的普通输出字节数:> URLSessionWebSocketTask 默认 1 MiB(1_048_576)。
|
||
static let bulkOutputBytes = 1_500_000
|
||
/// 对抗用例:"\u{1B}[0m"(4 字节)重复次数 → 原始 1.6 MB,
|
||
/// JSON 转义后 ≈ ×2.25 ≈ 3.6 MB(>1MiB、<16MiB、<2MiB ring 容量)。
|
||
static let escSequenceRepeats = 400_000
|
||
static let escSequenceRawBytes = escSequenceRepeats * 4
|
||
/// Origin 失配用端口(若真实端口撞车则退避到 fallback)。
|
||
static let mismatchedOriginPort = 9999
|
||
static let mismatchedOriginPortFallback = 9998
|
||
static let serverReadyTimeout: Duration = .seconds(40)
|
||
static let serverReadyPollInterval: Duration = .milliseconds(200)
|
||
/// 单帧等待上限(attach→attached 等短往返)。
|
||
static let frameTimeout: Duration = .seconds(15)
|
||
/// 等待某个 shell 输出标记出现的上限(含 bash 冷启动)。
|
||
static let markerTimeout: Duration = .seconds(30)
|
||
/// 大流量累计(>1MiB 灌入/回放)的上限。
|
||
static let outputAccumulationTimeout: Duration = .seconds(90)
|
||
/// 等待服务器主动关闭 WS 的上限(kill 路径)。
|
||
static let closeObserveTimeout: Duration = .seconds(30)
|
||
/// F3「零重连」的观察窗口:必须显著大于 `ReconnectMachine` 的第一级退避(1s),
|
||
/// 这样"没有重连"才是结论而不是"还没轮到"。3.5s 覆盖 1s + 2s 两级。
|
||
static let retryObservationWindow: Duration = .milliseconds(3500)
|
||
}
|
||
|
||
enum HarnessError: Error, CustomStringConvertible {
|
||
case setup(String)
|
||
case timeout(String)
|
||
|
||
var description: String {
|
||
switch self {
|
||
case .setup(let detail): return "setup: \(detail)"
|
||
case .timeout(let detail): return "timeout: \(detail)"
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── 令牌网关服务器(F3;令牌是密级材料,见下方纪律注释) ─────────────────────
|
||
|
||
/// 一台启用了 `WEBTERM_TOKEN` 的服务器 + 打开它的那枚一次性令牌。
|
||
///
|
||
/// 密级纪律(§1.1):`token` 只在进程内存里传递给 `URLRequest`/`tokenProvider`。
|
||
/// **绝不**插进断言文案、日志、URL query —— 用例失败时只报状态码与判定结果。
|
||
struct TokenGatedServer: Sendable {
|
||
let server: TestServer
|
||
let token: String
|
||
|
||
/// 一枚**错误但形状合法**的令牌(同长度、同字符集)。用于 401 用例:形状
|
||
/// 非法的令牌会被客户端自己挡掉(`AuthCookie.headerValue` 返回 nil),那测的
|
||
/// 就不是服务器的门了。
|
||
var wrongButWellFormedToken: String {
|
||
// 首字符换成集合内的另一个字符:长度/字符集不变,值必不相同。
|
||
let replacement: Character = token.first == "z" ? "y" : "z"
|
||
return String(replacement) + token.dropFirst()
|
||
}
|
||
}
|
||
|
||
/// 生成一次性令牌:32 个字符,取自 §1.1 字符集的 ASCII 字母数字子集
|
||
/// (子集足够——测的是服务器的门,不是字符集本身;字符集由 TokenPolicyDriftTests 守)。
|
||
func makeThrowawayToken(length: Int = 32) -> String {
|
||
let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
|
||
var generator = SystemRandomNumberGenerator()
|
||
let characters = (0..<length).map { _ -> Character in
|
||
alphabet[Int.random(in: 0..<alphabet.count, using: &generator)]
|
||
}
|
||
return String(characters)
|
||
}
|
||
|
||
// ─── 服务器端点快照(不可变;派生全部走 HostEndpoint 冻结契约) ────────────────
|
||
|
||
struct TestServer: Sendable {
|
||
let endpoint: HostEndpoint
|
||
|
||
var baseURL: URL { endpoint.baseURL }
|
||
var wsURL: URL { endpoint.wsURL }
|
||
/// 白名单内的 Origin —— 由 HostEndpoint 单点派生(plan §5.1,禁止手拼)。
|
||
var origin: String { endpoint.originHeader }
|
||
var port: Int { endpoint.baseURL.port ?? (endpoint.baseURL.scheme == "https" ? 443 : 80) }
|
||
|
||
/// 显式校验 + 派生(服务器/env 是不可信输入,边界处全验证)。
|
||
static func make(baseURL: URL) throws -> TestServer {
|
||
guard let endpoint = HostEndpoint(baseURL: baseURL) else {
|
||
throw HarnessError.setup("server URL 必须是带 host 的 http(s),got \(baseURL)")
|
||
}
|
||
return TestServer(endpoint: endpoint)
|
||
}
|
||
}
|
||
|
||
// ─── 服务器进程回收(death-pipe watchdog + atexit 双保险,防孤儿 tsx) ────────
|
||
//
|
||
// 实测(T-iOS-2):Swift Testing runner 退出时不跑 atexit handler(首轮运行留下
|
||
// 孤儿 tsx),所以主回收机制是 death-pipe:spawn 一个 /bin/sh watchdog,其 stdin
|
||
// 是本测试进程持有的管道写端 —— 测试进程无论以何种方式退出(正常/崩溃),写端
|
||
// 关闭 → watchdog 的 `read` 收到 EOF → kill 服务器 pid。atexit 仅作快速路径兜底。
|
||
|
||
private enum ServerProcessRegistry {
|
||
private static let lock = NSLock()
|
||
/// F3:从单值改成数组 —— 现在同时跑两台服务器(普通 + 令牌网关)。若继续用
|
||
/// 单值持有,第二次 register 会覆盖第一台的 deathPipe,写端被 deinit 关闭 →
|
||
/// 第一台的 watchdog 立刻收到 EOF 并把还在用的服务器杀掉。
|
||
nonisolated(unsafe) private static var processes: [Process] = []
|
||
/// 持有写端防 deinit 关闭;测试进程死亡时由内核关闭 → watchdog 触发。
|
||
nonisolated(unsafe) private static var deathPipes: [Pipe] = []
|
||
nonisolated(unsafe) private static var installedAtexit = false
|
||
|
||
static func register(_ p: Process) {
|
||
lock.lock()
|
||
defer { lock.unlock() }
|
||
processes.append(p)
|
||
if let pipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier) {
|
||
deathPipes.append(pipe)
|
||
}
|
||
guard !installedAtexit else { return }
|
||
installedAtexit = true
|
||
atexit { ServerProcessRegistry.killNow() }
|
||
}
|
||
|
||
static func killNow() {
|
||
lock.lock()
|
||
defer { lock.unlock() }
|
||
for p in processes where p.isRunning { p.terminate() }
|
||
processes = []
|
||
}
|
||
|
||
private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe {
|
||
let pipe = Pipe()
|
||
let watchdog = Process()
|
||
watchdog.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||
watchdog.arguments = ["-c", "read _; kill \(serverPid) 2>/dev/null"]
|
||
watchdog.standardInput = pipe
|
||
watchdog.standardOutput = FileHandle.nullDevice
|
||
watchdog.standardError = FileHandle.nullDevice
|
||
try watchdog.run()
|
||
return pipe
|
||
}
|
||
}
|
||
|
||
// ─── 服务器 harness(actor 单例:spawn 一次,全部 suite 复用) ─────────────────
|
||
//
|
||
// Swift Testing 默认跨 suite 并行 → 多个 suite 会并发调 server()。actor 是可重入
|
||
// 的:首个调用在 awaitReady 挂起时,后来者会重入进来 —— 若用"缓存成品"模式就会
|
||
// 重复 spawn。所以缓存的是 in-flight Task(同步写入,后来者 await 同一个)。
|
||
|
||
actor ServerHarness {
|
||
static let shared = ServerHarness()
|
||
private var bootTask: Task<TestServer, any Error>?
|
||
private var tokenBootTask: Task<TokenGatedServer, any Error>?
|
||
|
||
func server() async throws -> TestServer {
|
||
if let bootTask { return try await bootTask.value }
|
||
let task = Task { try await Self.bootstrap() }
|
||
bootTask = task
|
||
return try await task.value
|
||
}
|
||
|
||
/// F3:开了 `WEBTERM_TOKEN` 的第二台服务器(同样"起一次、全 suite 复用")。
|
||
func tokenGatedServer() async throws -> TokenGatedServer {
|
||
if let tokenBootTask { return try await tokenBootTask.value }
|
||
let task = Task { try await Self.bootstrapTokenGated() }
|
||
tokenBootTask = task
|
||
return try await task.value
|
||
}
|
||
|
||
private static func bootstrap() async throws -> TestServer {
|
||
if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] {
|
||
guard let url = URL(string: external) else {
|
||
throw HarnessError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)")
|
||
}
|
||
let server = try TestServer.make(baseURL: url)
|
||
try await awaitReady(server, process: nil, logURL: nil, cookie: nil)
|
||
return server
|
||
}
|
||
let spawned = try spawnLocalServer()
|
||
ServerProcessRegistry.register(spawned.process)
|
||
do {
|
||
try await awaitReady(
|
||
spawned.server, process: spawned.process, logURL: spawned.logURL, cookie: nil)
|
||
} catch {
|
||
ServerProcessRegistry.killNow()
|
||
throw error
|
||
}
|
||
return spawned.server
|
||
}
|
||
|
||
private static func bootstrapTokenGated() async throws -> TokenGatedServer {
|
||
let environment = ProcessInfo.processInfo.environment
|
||
if let external = environment["WEBTERM_TOKEN_SERVER_URL"] {
|
||
guard let token = environment["WEBTERM_TOKEN_SERVER_TOKEN"], !token.isEmpty else {
|
||
throw HarnessError.setup(
|
||
"设了 WEBTERM_TOKEN_SERVER_URL 就必须同时设 WEBTERM_TOKEN_SERVER_TOKEN")
|
||
}
|
||
guard let url = URL(string: external) else {
|
||
throw HarnessError.setup("WEBTERM_TOKEN_SERVER_URL 不是合法 URL: \(external)")
|
||
}
|
||
let server = try TestServer.make(baseURL: url)
|
||
let gated = TokenGatedServer(server: server, token: token)
|
||
// 就绪探针必须带 cookie:令牌网关下 GET /live-sessions 无 cookie 是 401。
|
||
try await awaitReady(
|
||
server, process: nil, logURL: nil, cookie: authCookieHeader(token: token))
|
||
return gated
|
||
}
|
||
let token = makeThrowawayToken()
|
||
let spawned = try spawnLocalServer(extraEnvironment: ["WEBTERM_TOKEN": token])
|
||
ServerProcessRegistry.register(spawned.process)
|
||
do {
|
||
try await awaitReady(
|
||
spawned.server, process: spawned.process, logURL: spawned.logURL,
|
||
cookie: authCookieHeader(token: token))
|
||
} catch {
|
||
ServerProcessRegistry.killNow()
|
||
throw error
|
||
}
|
||
return TokenGatedServer(server: spawned.server, token: token)
|
||
}
|
||
|
||
/// T-iOS-32:起一台**一次性**服务器 —— 给需要 bespoke 进程环境的用例用。
|
||
///
|
||
/// 为什么不能复用 `server()`:`GET /sessions` 的数据源是
|
||
/// `os.homedir()/.claude/projects`(src/http/history.ts:81),只有把子进程的
|
||
/// `HOME` 换掉才能让它**确定**(否则断言就落在开发者本机的真实 Claude 历史上:
|
||
/// CI 上那个目录根本不存在 ⇒ 恒 `[]` ⇒ 断言恒真,等于没测;本机上又会把真实
|
||
/// 会话内容读进测试进程)。`HOME` 只在启动时经 env 传一次,同一进程无法既是
|
||
/// 隔离的又是真实的 —— 与 `tokenGatedServer()` 需要第二台是同一个理由。
|
||
///
|
||
/// 不进 shared 缓存(每次调用一台),但**照样**登记进 `ServerProcessRegistry`,
|
||
/// 所以 death-pipe watchdog 一样管它,测试进程无论怎么死都不留孤儿 tsx。
|
||
/// 就绪失败时只 `terminate()` 自己这一台 —— 绝不调 `killNow()`(那会连别的
|
||
/// suite 正在用的共享服务器一起杀掉)。
|
||
///
|
||
/// 只走自举一条路:外部服务器(`WEBTERM_SERVER_URL`)的 `HOME` 不在我们手里,
|
||
/// 拿不到本方法存在的理由,所以这里不接受外部模式。
|
||
static func disposableServer(extraEnvironment: [String: String]) async throws -> TestServer {
|
||
let spawned = try spawnLocalServer(extraEnvironment: extraEnvironment)
|
||
ServerProcessRegistry.register(spawned.process)
|
||
do {
|
||
try await awaitReady(
|
||
spawned.server, process: spawned.process, logURL: spawned.logURL, cookie: nil)
|
||
} catch {
|
||
spawned.process.terminate()
|
||
throw error
|
||
}
|
||
return spawned.server
|
||
}
|
||
|
||
private static func spawnLocalServer(
|
||
extraEnvironment: [String: String] = [:]
|
||
) throws -> (server: TestServer, process: Process, logURL: URL) {
|
||
let repoRoot = try locateRepoRoot()
|
||
let tsx = try locateTsx(from: repoRoot)
|
||
let port = try findFreeLoopbackPort()
|
||
guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else {
|
||
throw HarnessError.setup("无法构造 baseURL, port=\(port)")
|
||
}
|
||
let server = try TestServer.make(baseURL: baseURL)
|
||
|
||
let logURL = FileManager.default.temporaryDirectory
|
||
.appending(path: "webterm-integration-server-\(port).log")
|
||
FileManager.default.createFile(atPath: logURL.path, contents: nil)
|
||
let logHandle = try FileHandle(forWritingTo: logURL)
|
||
|
||
let process = Process()
|
||
process.executableURL = tsx
|
||
process.arguments = ["src/server.ts"]
|
||
process.currentDirectoryURL = repoRoot
|
||
var env = ProcessInfo.processInfo.environment
|
||
env["PORT"] = String(port)
|
||
env["BIND_HOST"] = "127.0.0.1" // 测试期间绝不监听 0.0.0.0
|
||
env["SHELL_PATH"] = "/bin/bash" // 确定性输出(不吃用户 zsh 配置)
|
||
env["USE_TMUX"] = "0"
|
||
env["PATH"] = (env["PATH"] ?? "") + ":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
|
||
// F3:令牌经 env 传给子进程 —— 与真实部署完全同一通路(`WEBTERM_TOKEN`),
|
||
// 且不落盘、不进命令行(`ps` 看不到 env 之外的东西)。
|
||
for (key, value) in extraEnvironment { env[key] = value }
|
||
process.environment = env
|
||
process.standardOutput = logHandle
|
||
process.standardError = logHandle
|
||
try process.run()
|
||
return (server, process, logURL)
|
||
}
|
||
|
||
/// - Parameter cookie: 令牌网关服务器的就绪探针必须带 `Cookie: webterm_auth=…`,
|
||
/// 否则 `GET /live-sessions` 恒 401(src/server.ts:459),会误判成"未就绪"。
|
||
private static func awaitReady(
|
||
_ server: TestServer, process: Process?, logURL: URL?, cookie: String?
|
||
) async throws {
|
||
var probe = URLRequest(url: server.baseURL.appending(path: "live-sessions"))
|
||
if let cookie { probe.setValue(cookie, forHTTPHeaderField: "Cookie") }
|
||
let deadline = ContinuousClock.now + HarnessTunables.serverReadyTimeout
|
||
while ContinuousClock.now < deadline {
|
||
if let process, !process.isRunning {
|
||
throw HarnessError.setup(
|
||
"服务器进程提前退出(exit \(process.terminationStatus))— 日志: \(logURL?.path ?? "n/a")")
|
||
}
|
||
if let (data, response) = try? await cookieFreeSession.data(for: probe),
|
||
(response as? HTTPURLResponse)?.statusCode == 200,
|
||
(try? JSONSerialization.jsonObject(with: data)) is [Any] {
|
||
return
|
||
}
|
||
try await Task.sleep(for: HarnessTunables.serverReadyPollInterval)
|
||
}
|
||
throw HarnessError.timeout(
|
||
"服务器 \(server.baseURL) 在 \(HarnessTunables.serverReadyTimeout) 内未就绪 — 日志: \(logURL?.path ?? "n/a")")
|
||
}
|
||
}
|
||
|
||
/// `node_modules/.bin/tsx`:从 repo 根**逐级向上**找。
|
||
///
|
||
/// 为什么向上找:会话工作在 `.claude/worktrees/<name>/` 里,worktree 没有自己的
|
||
/// `node_modules`;Node 自己的模块解析同样是逐级向上,所以 worktree 里的
|
||
/// `src/server.ts` 本来就跑得起来(依赖解析到主 checkout 的 node_modules)。
|
||
/// 只查根目录会把这种完全正常的布局误判成"没装依赖"。
|
||
func locateTsx(from repoRoot: URL) throws -> URL {
|
||
var directory = repoRoot.standardizedFileURL
|
||
var visited: [String] = []
|
||
while true {
|
||
let candidate = directory.appending(path: "node_modules/.bin/tsx")
|
||
if FileManager.default.isExecutableFile(atPath: candidate.path) { return candidate }
|
||
visited.append(candidate.path)
|
||
let parent = directory.deletingLastPathComponent().standardizedFileURL
|
||
if parent.path == directory.path { break }
|
||
directory = parent
|
||
}
|
||
throw HarnessError.setup(
|
||
"找不到 node_modules/.bin/tsx(逐级向上查过 \(visited.count) 处,"
|
||
+ "从 \(visited.first ?? "?") 起)— 先在 repo 根目录跑 npm install/npm ci")
|
||
}
|
||
|
||
/// `Cookie: webterm_auth=<token>` 的头**值**(名字取自服务端 `AUTH_COOKIE_NAME`,
|
||
/// 由 TokenPolicyDriftTests 对钉 src/http/auth.ts)。
|
||
func authCookieHeader(token: String) -> String {
|
||
"webterm_auth=\(token)"
|
||
}
|
||
|
||
/// 关掉 cookie jar 的会话:手写的 `Cookie` 头是唯一权威(§1.1 冻结),任何主机的
|
||
/// `Set-Cookie` 都不许覆盖它、也不许把密级材料留在共享存储里
|
||
/// (与 `SessionCore.WSConnection.sessionConfiguration` 同一纪律)。
|
||
let cookieFreeSession: URLSession = {
|
||
let configuration = URLSessionConfiguration.ephemeral
|
||
configuration.httpShouldSetCookies = false
|
||
configuration.httpCookieAcceptPolicy = .never
|
||
configuration.httpCookieStorage = nil
|
||
return URLSession(configuration: configuration)
|
||
}()
|
||
|
||
func locateRepoRoot() throws -> URL {
|
||
if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] {
|
||
return URL(fileURLWithPath: override, isDirectory: true)
|
||
}
|
||
// #filePath = <repo>/ios/IntegrationTests/ServerHarness.swift
|
||
return URL(fileURLWithPath: #filePath)
|
||
.deletingLastPathComponent() // IntegrationTests
|
||
.deletingLastPathComponent() // ios
|
||
.deletingLastPathComponent() // repo root
|
||
}
|
||
|
||
/// bind(port=0) 拿一个空闲 loopback 端口(close 后交给服务器绑定;竞态窗口可忽略,
|
||
/// 若真撞车 awaitReady 会用日志报错)。
|
||
func findFreeLoopbackPort() throws -> Int {
|
||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||
guard fd >= 0 else { throw HarnessError.setup("socket() 失败: errno \(errno)") }
|
||
defer { close(fd) }
|
||
|
||
var addr = sockaddr_in()
|
||
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||
addr.sin_family = sa_family_t(AF_INET)
|
||
addr.sin_port = 0
|
||
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
|
||
let bound = withUnsafePointer(to: &addr) {
|
||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||
bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||
}
|
||
}
|
||
guard bound == 0 else { throw HarnessError.setup("bind() 失败: errno \(errno)") }
|
||
|
||
var out = sockaddr_in()
|
||
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||
let named = withUnsafeMutablePointer(to: &out) {
|
||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &len) }
|
||
}
|
||
guard named == 0 else { throw HarnessError.setup("getsockname() 失败: errno \(errno)") }
|
||
return Int(UInt16(bigEndian: out.sin_port))
|
||
}
|