// // 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:(src/config.ts:187-226)。 // 日志落在 $TMPDIR/webterm-integration-server-.log。 // B. 外部服务器:WEBTERM_SERVER_URL=http://127.0.0.1:(须为 loopback, // 这样其自身 origin 天然在白名单内)。 // // 与 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) } 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)" } } } // ─── 服务器端点快照(不可变;派生全部走 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() nonisolated(unsafe) private static var process: Process? /// 持有写端防 deinit 关闭;测试进程死亡时由内核关闭 → watchdog 触发。 nonisolated(unsafe) private static var deathPipe: Pipe? nonisolated(unsafe) private static var installedAtexit = false static func register(_ p: Process) { lock.lock() defer { lock.unlock() } process = p deathPipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier) guard !installedAtexit else { return } installedAtexit = true atexit { ServerProcessRegistry.killNow() } } static func killNow() { lock.lock() defer { lock.unlock() } if let p = process, p.isRunning { p.terminate() } process = nil } 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? 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 } 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) return server } let spawned = try spawnLocalServer() ServerProcessRegistry.register(spawned.process) do { try await awaitReady(spawned.server, process: spawned.process, logURL: spawned.logURL) } catch { ServerProcessRegistry.killNow() throw error } return spawned.server } private static func spawnLocalServer() throws -> (server: TestServer, process: Process, logURL: URL) { let repoRoot = try locateRepoRoot() let tsx = repoRoot.appending(path: "node_modules/.bin/tsx") guard FileManager.default.isExecutableFile(atPath: tsx.path) else { throw HarnessError.setup("找不到 \(tsx.path) — 先在 repo 根目录跑 npm install/npm ci") } 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" process.environment = env process.standardOutput = logHandle process.standardError = logHandle try process.run() return (server, process, logURL) } private static func awaitReady(_ server: TestServer, process: Process?, logURL: URL?) async throws { let probeURL = server.baseURL.appending(path: "live-sessions") 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 URLSession.shared.data(from: probeURL), (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")") } } func locateRepoRoot() throws -> URL { if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] { return URL(fileURLWithPath: override, isDirectory: true) } // #filePath = /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.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.size)) } } guard bound == 0 else { throw HarnessError.setup("bind() 失败: errno \(errno)") } var out = sockaddr_in() var len = socklen_t(MemoryLayout.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)) }