// // SimServerHarness.swift — T-iOS-15 hosted live-server smoke 的基础设施。 // // WebTermTests 跑在【模拟器内的签名 App 宿主】里:iOS SDK 没有 // Foundation.Process,但模拟器进程本质是宿主 mac 上的原生进程 —— // `posix_spawn`(iOS 2.0+ 可用,见 SDK spawn.h)在模拟器上直接工作, // 模拟器也能直达宿主 loopback(App 的 ATS 127.0.0.0/8 例外已在 project.yml)。 // // 生命周期与 ios/IntegrationTests/ServerHarness.swift 同思路(death-pipe): // 只 spawn 一个 /bin/sh,它把真服务器(node_modules/.bin/tsx src/server.ts) // 拉起为后台子进程后阻塞在 `read`(stdin = 本测试进程持有写端的管道)。 // 测试进程无论以何种方式退出,写端被内核关闭 → `read` 得 EOF → sh `kill` 服务器。 // 无孤儿、无需 atexit。 // // 运行模式(同 ServerHarness 的 A/B 两式): // A. 自举(默认):repo 不在 TCC 保护目录时,测试进程内 posix_spawn 起真服务器。 // B. 外部服务器:TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1: // (TEST_RUNNER_ 前缀是 xcodebuild 向测试进程透传 env 的官方通道)。 // repo 根可用 TEST_RUNNER_WEBTERM_REPO_ROOT 覆盖(默认由 #filePath 推导)。 // // TCC 硬墙(T-iOS-15 实测,2026-07-05):repo 位于 ~/Documents|Desktop|Downloads // 时模式 A 必挂——模拟器 App 语境 spawn 的 node 打开该目录下文件会被 tccd 无限 // 阻塞(sample 实测主线程卡 uv_cwd → __open_nocancel;sh 内 head 同样挂起; // /bin/sh、/bin/date 等平台二进制不受影响)。此时 harness 快速失败并给出模式 B // 的可操作指引,绝不无声挂 60 秒。CI checkout(如 /Users/runner/work)不受影响。 import Darwin import Foundation import WireProtocol enum SmokeTunables { static let serverReadyTimeout: Duration = .seconds(60) static let serverReadyPollInterval: Duration = .milliseconds(200) /// bash 冷启动 + echo 往返的总预算。 static let outputTimeout: Duration = .seconds(45) /// tsx shebang 是 `#!/usr/bin/env node` —— sh 命令里显式前置常见 node 安装位。 static let spawnPathPrefix = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin" } enum SmokeHarnessError: 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)" } } } /// spawn 一次、全部用例复用(缓存 in-flight Task,避免 actor 可重入期间重复 spawn)。 actor SimServerHarness { static let shared = SimServerHarness() private var bootTask: Task? /// death-pipe 写端:持有到测试进程退出(内核代为关闭 → 服务器被回收)。 private var deathPipeWriteFD: Int32? func endpoint() async throws -> HostEndpoint { if let bootTask { return try await bootTask.value } let task = Task { try await self.bootstrap() } bootTask = task return try await task.value } private func bootstrap() async throws -> HostEndpoint { let env = ProcessInfo.processInfo.environment if let external = env["WEBTERM_SERVER_URL"] { guard let url = URL(string: external), let endpoint = HostEndpoint(baseURL: url) else { throw SmokeHarnessError.setup("WEBTERM_SERVER_URL 不是合法 http(s) URL: \(external)") } try await Self.awaitReady(endpoint, logPath: nil) return endpoint } let repoRoot = Self.locateRepoRoot(environment: env) if let folder = Self.tccProtectedFolder(containing: repoRoot.path) { throw SmokeHarnessError.setup(Self.tccGuidance(folder: folder, repoRoot: repoRoot.path)) } let tsxPath = repoRoot.appending(path: "node_modules/.bin/tsx").path guard FileManager.default.isExecutableFile(atPath: tsxPath) else { throw SmokeHarnessError.setup("找不到 \(tsxPath) — 先在 repo 根目录跑 npm install/npm ci") } let port = try Self.findFreeLoopbackPort() guard let baseURL = URL(string: "http://127.0.0.1:\(port)"), let endpoint = HostEndpoint(baseURL: baseURL) else { throw SmokeHarnessError.setup("无法构造 endpoint, port=\(port)") } let logPath = FileManager.default.temporaryDirectory .appending(path: "webterm-hosted-smoke-\(port).log").path deathPipeWriteFD = try Self.spawnServerShell( repoRoot: repoRoot.path, port: port, logPath: logPath ) try await Self.awaitReady(endpoint, logPath: logPath) return endpoint } // MARK: - Spawn (posix_spawn + death-pipe) /// Returns the pipe WRITE fd the caller must keep open for the server's /// lifetime (closing it — including by process death — kills the server). private static func spawnServerShell( repoRoot: String, port: Int, logPath: String ) throws -> Int32 { var pipeFDs: [Int32] = [0, 0] guard pipe(&pipeFDs) == 0 else { throw SmokeHarnessError.setup("pipe() 失败: errno \(errno)") } let (readFD, writeFD) = (pipeFDs[0], pipeFDs[1]) let command = """ cd '\(repoRoot)' || exit 1 echo "[harness] sh up $(date +%T)" >> '\(logPath)' PORT=\(port) BIND_HOST=127.0.0.1 SHELL_PATH=/bin/bash USE_TMUX=0 \ PATH="\(SmokeTunables.spawnPathPrefix):$PATH" \ node_modules/.bin/tsx src/server.ts >> '\(logPath)' 2>&1 & SRV=$! read _ echo "[harness] death-pipe EOF $(date +%T) — killing $SRV" >> '\(logPath)' kill $SRV 2>/dev/null """ var fileActions: posix_spawn_file_actions_t? posix_spawn_file_actions_init(&fileActions) defer { posix_spawn_file_actions_destroy(&fileActions) } posix_spawn_file_actions_adddup2(&fileActions, readFD, 0) // sh 的 stdin = 管道读端 posix_spawn_file_actions_addclose(&fileActions, readFD) posix_spawn_file_actions_addclose(&fileActions, writeFD) // 子进程绝不持有写端 let argv = ["/bin/sh", "-c", command] var argvC: [UnsafeMutablePointer?] = argv.map { strdup($0) } argvC.append(nil) // MINIMAL clean env — the simulator app's own environment is poison // for host binaries: its DYLD_ROOT_PATH/DYLD_FALLBACK_* point into the // iOS simulator runtime. /bin/sh is SIP-protected (dyld ignores DYLD_* // there), but /usr/local|homebrew node is NOT and dies at dyld stage // before printing anything. Only PATH + writable HOME/TMPDIR survive. let inherited = ProcessInfo.processInfo.environment let cleanEnv = [ "PATH": SmokeTunables.spawnPathPrefix, "HOME": inherited["HOME"] ?? NSTemporaryDirectory(), "TMPDIR": inherited["TMPDIR"] ?? NSTemporaryDirectory(), ] var envC: [UnsafeMutablePointer?] = cleanEnv .map { strdup("\($0.key)=\($0.value)") } envC.append(nil) defer { for pointer in argvC { free(pointer) } for pointer in envC { free(pointer) } } var pid: pid_t = 0 let status = posix_spawn(&pid, "/bin/sh", &fileActions, nil, argvC, envC) close(readFD) // 父进程不需要读端 guard status == 0 else { close(writeFD) throw SmokeHarnessError.setup("posix_spawn(/bin/sh) 失败: \(status)") } return writeFD } // MARK: - Readiness private static func awaitReady(_ endpoint: HostEndpoint, logPath: String?) async throws { let probeURL = endpoint.baseURL.appending(path: "live-sessions") let deadline = ContinuousClock.now + SmokeTunables.serverReadyTimeout while ContinuousClock.now < deadline { 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: SmokeTunables.serverReadyPollInterval) } throw SmokeHarnessError.timeout( "服务器 \(endpoint.baseURL) 在 \(SmokeTunables.serverReadyTimeout) 内未就绪 — 日志: \(logPath ?? "n/a")" ) } // MARK: - TCC guard (fail fast, actionable — see header) private static let tccProtectedFolders: Set = ["Documents", "Desktop", "Downloads"] /// `/Users//(Documents|Desktop|Downloads)/…` → the protected folder /// name, else nil. Host-path heuristic (the sim's own HOME is a container /// path, so the host home cannot come from the environment). static func tccProtectedFolder(containing path: String) -> String? { let components = URL(fileURLWithPath: path).pathComponents guard components.count > 3, components[1] == "Users", tccProtectedFolders.contains(components[3]) else { return nil } return components[3] } private static func tccGuidance(folder: String, repoRoot: String) -> String { """ repo 位于 macOS TCC 保护目录(~/\(folder)):模拟器沙箱内 spawn 的 node \ 读取该目录会被 tccd 无限阻塞(实测 open() 挂起),自举模式必挂。请在宿主机 \ 自行起服务器后以外部模式重跑: cd '\(repoRoot)' && PORT=

BIND_HOST=127.0.0.1 SHELL_PATH=/bin/bash USE_TMUX=0 npm start & TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:

xcodebuild … test """ } // MARK: - Locations private static func locateRepoRoot(environment: [String: String]) -> URL { if let override = environment["WEBTERM_REPO_ROOT"] { return URL(fileURLWithPath: override, isDirectory: true) } // #filePath = /ios/App/WebTermTests/SimServerHarness.swift return URL(fileURLWithPath: #filePath) .deletingLastPathComponent() // WebTermTests .deletingLastPathComponent() // App .deletingLastPathComponent() // ios .deletingLastPathComponent() // repo root } /// bind(port=0) 拿空闲 loopback 端口(同 IntegrationTests 手法;竞态窗口可忽略)。 private static func findFreeLoopbackPort() throws -> Int { let fd = socket(AF_INET, SOCK_STREAM, 0) guard fd >= 0 else { throw SmokeHarnessError.setup("socket() 失败: errno \(errno)") } defer { close(fd) } var address = sockaddr_in() address.sin_len = UInt8(MemoryLayout.size) address.sin_family = sa_family_t(AF_INET) address.sin_port = 0 address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) let bound = withUnsafePointer(to: &address) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, socklen_t(MemoryLayout.size)) } } guard bound == 0 else { throw SmokeHarnessError.setup("bind() 失败: errno \(errno)") } var assigned = sockaddr_in() var length = socklen_t(MemoryLayout.size) let named = withUnsafeMutablePointer(to: &assigned) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &length) } } guard named == 0 else { throw SmokeHarnessError.setup("getsockname() 失败: errno \(errno)") } return Int(UInt16(bigEndian: assigned.sin_port)) } }