Files
web-terminal/ios/App/WebTermTests/SimServerHarness.swift
Yaojia Wang cc4d3129cc feat(ios): W4 app wiring — DI graph, event fan-out, lifecycle, privacy shade
T-iOS-15: AppEnvironment production DI (real Keychain/probe/transports), EventFanOut
(engine.events → TerminalVM + GateVM + activity bridge), scenePhase lifecycle
(.background→close, suspend→rebuild+reopen reclaims size), privacy shade on != .active,
spike screen deleted, cold-start routing
Walkthrough proxies: hosted live-server smoke over the production DI graph (probe→store→
attach→echo→close) + real-Keychain kSecAttrAccessible assertion (closes T-iOS-7 deferral)
Deviation closed: TerminalViewModel.lastSentDims (valid-only) + alive-engine .active →
notifyForegrounded(dims:) (orchestrator fix on behalf of T-iOS-11 owner)
Env finding: repo under ~/Documents → TCC blocks sim-spawned node; SimServerHarness dual-mode
(self-bootstrap / WEBTERM_SERVER_URL via simctl launchctl setenv)
Verified: 214 pkg + 76 app + 10 integration tests green; verify agent 8/8 PASS
2026-07-05 01:04:42 +02:00

247 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// SimServerHarness.swift T-iOS-15 hosted live-server smoke
//
// WebTermTests App 宿iOS SDK
// Foundation.Process宿 mac
// `posix_spawn`iOS 2.0+ SDK spawn.h
// 宿 loopbackApp ATS 127.0.0.0/8 project.yml
//
// ios/IntegrationTests/ServerHarness.swift death-pipe
// spawn /bin/shnode_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:<port>
// TEST_RUNNER_ xcodebuild env
// repo TEST_RUNNER_WEBTERM_REPO_ROOT #filePath
//
// TCC T-iOS-15 2026-07-05repo ~/Documents|Desktop|Downloads
// A App spawn node tccd
// sample 线 uv_cwd __open_nocancelsh 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<HostEndpoint, any Error>?
/// 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<CChar>?] = 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<CChar>?] = 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<String> = ["Documents", "Desktop", "Downloads"]
/// `/Users/<name>/(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=<p> 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:<p> 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 = <repo>/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<sockaddr_in>.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<sockaddr_in>.size))
}
}
guard bound == 0 else { throw SmokeHarnessError.setup("bind() 失败: errno \(errno)") }
var assigned = sockaddr_in()
var length = socklen_t(MemoryLayout<sockaddr_in>.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))
}
}