Files
web-terminal/ios/IntegrationTests/OriginSpikeTests.swift
Yaojia Wang cbaa08daba feat(ios): W0 scaffold + day-1 spike + WireProtocol frozen contract + TestSupport doubles
T-iOS-1: ios/ XcodeGen project (iOS 17, Swift 6 strict concurrency, ATS per PLAN §5.2), 5 SPM package shells, CI skeleton
T-iOS-2: Origin spike vs real server — URLSessionWebSocketTask custom Origin CONFIRMED (no Starscream); 16MiB replay + EMSGSIZE(40) errno correction written back to plan
T-iOS-3: WireProtocol frozen contract, 59 tests, 100% line coverage, cross-impl vectors vs src/protocol.ts via tsx
T-iOS-4: FakeTransport/FakeClock/FakeHTTPTransport doubles
Verify: independent agent re-ran all acceptance — 6/6 PASS
2026-07-04 21:19:30 +02:00

592 lines
28 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

//
// OriginSpikeTests.swift T-iOS-2 Day-1 spike
//
// Node URLSessionWebSocketTask make-or-break
// plan §7 T-iOS-2
// Origin WS 401 src/server.ts:646-651 Origin
// `Origin` header URLSessionWebSocketTask
// attach(null) attached MED
// ring-buffer src/session/session.ts attachWs buffer.snapshot()
// maximumMessageSize=1MiB >1MiB withKnownIssue
// 16 MiB ESC/C0 JSON \uXXXX
// Originhttp://127.0.0.1:9999 401src/http/origin.ts:47-51
// :443 new URL()
//
//
// A. `swift test --package-path ios/IntegrationTests`
// spawn `node_modules/.bin/tsx src/server.ts`repo
// #filePath WEBTERM_REPO_ROOT 127.0.0.1
// SHELL_PATH=/bin/bashUSE_TMUX=0 ALLOWED_ORIGINS
// deriveAllowedOrigins http://127.0.0.1:<port>src/config.ts:187-226
// atexit $TMPDIR/webterm-spike-server-<port>.log
// B. WEBTERM_SERVER_URL=http://127.0.0.1:<port> loopback
// origin
//
// ** import WireProtocol**T-iOS-3
// 16 MiB SpikeTunables WireProtocol.Tunables
import Darwin
import Foundation
import Testing
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
// WireProtocol.TunablesT-iOS-3
private enum SpikeTunables {
/// plan §3.2.1 `Tunables.maxWSMessageBytes` import
/// 6 × SCROLLBACK_BYTES(2 MiB) +
static let maxWSMessageBytes = 16 * 1024 * 1024
/// src/config.ts:41 DEFAULT_WS_PATH
static let wsPath = "/term"
/// > URLSessionWebSocketTask 1 MiB1_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
/// spike 退 fallback
static let mismatchedOriginPort = 9999
static let mismatchedOriginPortFallback = 9998
static let serverReadyTimeout: Duration = .seconds(40)
static let serverReadyPollInterval: Duration = .milliseconds(200)
static let frameTimeout: Duration = .seconds(15)
static let outputAccumulationTimeout: Duration = .seconds(90)
}
private enum SpikeError: 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)"
}
}
}
//
private struct SpikeServer: Sendable {
let baseURL: URL
let origin: String
let wsURL: URL
let port: Int
/// + /env
static func make(baseURL: URL) throws -> SpikeServer {
guard let scheme = baseURL.scheme, scheme == "http" || scheme == "https" else {
throw SpikeError.setup("server URL 必须是 http(s)got \(baseURL)")
}
guard let host = baseURL.host, !host.isEmpty else {
throw SpikeError.setup("server URL 缺 host: \(baseURL)")
}
guard var comps = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
throw SpikeError.setup("server URL 无法解析: \(baseURL)")
}
comps.scheme = scheme == "https" ? "wss" : "ws"
comps.path = SpikeTunables.wsPath
comps.query = nil
guard let wsURL = comps.url else {
throw SpikeError.setup("无法派生 ws URL: \(baseURL)")
}
// Origin spike
let origin = baseURL.port.map { "\(scheme)://\(host):\($0)" } ?? "\(scheme)://\(host)"
let port = baseURL.port ?? (scheme == "https" ? 443 : 80)
return SpikeServer(baseURL: baseURL, origin: origin, wsURL: wsURL, port: port)
}
}
// death-pipe watchdog + atexit tsx
//
// Swift Testing runner 退 atexit handler tsx
// death-pipespawn /bin/sh watchdog stdin
// 退/
// watchdog `read` EOF kill pidatexit
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
}
}
// harnessactor spawn suite
private actor ServerHarness {
static let shared = ServerHarness()
private var cached: SpikeServer?
func ensureServer() async throws -> SpikeServer {
if let cached { return cached }
let server: SpikeServer
if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] {
guard let url = URL(string: external) else {
throw SpikeError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)")
}
server = try SpikeServer.make(baseURL: url)
try await awaitReady(server, process: nil, logURL: nil)
} else {
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
}
server = spawned.server
}
cached = server
return server
}
private func spawnLocalServer() throws -> (server: SpikeServer, 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 SpikeError.setup("找不到 \(tsx.path) — 先在 repo 根目录跑 npm install")
}
let port = try findFreeLoopbackPort()
guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else {
throw SpikeError.setup("无法构造 baseURL, port=\(port)")
}
let server = try SpikeServer.make(baseURL: baseURL)
let logURL = FileManager.default.temporaryDirectory
.appending(path: "webterm-spike-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 func awaitReady(_ server: SpikeServer, process: Process?, logURL: URL?) async throws {
let probeURL = server.baseURL.appending(path: "live-sessions")
let deadline = ContinuousClock.now + SpikeTunables.serverReadyTimeout
while ContinuousClock.now < deadline {
if let process, !process.isRunning {
throw SpikeError.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: SpikeTunables.serverReadyPollInterval)
}
throw SpikeError.timeout(
"服务器 \(server.baseURL)\(SpikeTunables.serverReadyTimeout) 内未就绪 — 日志: \(logURL?.path ?? "n/a")")
}
}
private func locateRepoRoot() throws -> URL {
if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] {
return URL(fileURLWithPath: override, isDirectory: true)
}
// #filePath = <repo>/ios/IntegrationTests/OriginSpikeTests.swift
return URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // IntegrationTests
.deletingLastPathComponent() // ios
.deletingLastPathComponent() // repo root
}
/// bind(port=0) loopback close
/// awaitReady
private func findFreeLoopbackPort() throws -> Int {
let fd = socket(AF_INET, SOCK_STREAM, 0)
guard fd >= 0 else { throw SpikeError.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 SpikeError.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 SpikeError.setup("getsockname() 失败: errno \(errno)") }
return Int(UInt16(bigEndian: out.sin_port))
}
// WS task
private final class SpikeWSClient: @unchecked Sendable {
let task: URLSessionWebSocketTask
/// - Parameters:
/// - origin: nil = Origin headerspike
/// - maxMessageBytes: nil = 1 MiBspike
init(server: SpikeServer, origin: String?, maxMessageBytes: Int?) {
var request = URLRequest(url: server.wsURL)
request.timeoutInterval = 15
if let origin {
// Origin reserved-header setValue
request.setValue(origin, forHTTPHeaderField: "Origin")
}
let task = URLSession.shared.webSocketTask(with: request)
if let maxMessageBytes { task.maximumMessageSize = maxMessageBytes }
self.task = task
task.resume()
}
/// 401 101
var handshakeStatusCode: Int? {
(task.response as? HTTPURLResponse)?.statusCode
}
func sendFrame(_ object: [String: Any]) async throws {
let data = try JSONSerialization.data(withJSONObject: object)
guard let text = String(data: data, encoding: .utf8) else {
throw SpikeError.setup("帧序列化非 UTF-8")
}
try await task.send(.string(text))
}
/// binary
func receiveText(timeout: Duration) async throws -> String {
try await withTimeout(timeout) { [self] in
while true {
switch try await task.receive() {
case .string(let text): return text
case .data: continue
@unknown default: continue
}
}
}
}
/// / nilhandshake
func handshakeFailure(timeout: Duration) async -> (any Error)? {
do {
_ = try await receiveText(timeout: timeout)
return nil
} catch {
return error
}
}
func close() {
task.cancel(with: .normalClosure, reason: nil)
}
}
private func withTimeout<T: Sendable>(
_ timeout: Duration,
_ operation: @escaping @Sendable () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(for: timeout)
throw SpikeError.timeout("操作超过 \(timeout)")
}
guard let first = try await group.next() else {
throw SpikeError.timeout("task group 无结果")
}
group.cancelAll()
return first
}
}
// ""src/protocol.ts:45-86
private struct ServerFrame: Decodable {
let type: String
let data: String?
let sessionId: String?
let code: Int?
}
private func decodeFrame(_ text: String) -> ServerFrame? {
guard let data = text.data(using: .utf8) else { return nil }
// nil crash
return try? JSONDecoder().decode(ServerFrame.self, from: data)
}
//
/// attach sessionId null src/protocol.ts:132-134
private func attachFrame(sessionId: String?) -> [String: Any] {
["type": "attach", "sessionId": sessionId ?? NSNull()]
}
/// input Enter \r0x0D \nCLAUDE.md gotcha
private func inputFrame(command: String) -> [String: Any] {
["type": "input", "data": command + "\r"]
}
/// attach attached output/statusattachWs attached
/// snapshotsrc/session/session.ts:158-170 / src/server.ts:721-733
private func attachAndAwaitAttached(client: SpikeWSClient, sessionId: String?) async throws -> String {
try await client.sendFrame(attachFrame(sessionId: sessionId))
let deadline = ContinuousClock.now + SpikeTunables.frameTimeout
while ContinuousClock.now < deadline {
let text = try await client.receiveText(timeout: SpikeTunables.frameTimeout)
guard let frame = decodeFrame(text) else { continue }
if frame.type == "attached", let id = frame.sessionId { return id }
}
throw SpikeError.timeout("attach 后未收到 attached 帧")
}
/// output UTF-8 minBytes
private func accumulateOutput(client: SpikeWSClient, minBytes: Int) async throws -> Int {
var total = 0
let deadline = ContinuousClock.now + SpikeTunables.outputAccumulationTimeout
while ContinuousClock.now < deadline {
let text = try await client.receiveText(timeout: SpikeTunables.outputAccumulationTimeout)
guard let frame = decodeFrame(text), frame.type == "output" else { continue }
total += (frame.data ?? "").utf8.count
if total >= minBytes { return total }
}
throw SpikeError.timeout("输出累计 \(total)/\(minBytes) 字节后超时")
}
private struct ReplaySummary: Sendable {
let adoptedSessionId: String
let totalOutputBytes: Int
let containsSoftReset: Bool
}
/// reattach attached output minBytes
private func replayResult(
client: SpikeWSClient, sessionId: String, minBytes: Int
) async -> Result<ReplaySummary, any Error> {
do {
try await client.sendFrame(attachFrame(sessionId: sessionId))
var adopted: String?
var total = 0
var sawSoftReset = false
let deadline = ContinuousClock.now + SpikeTunables.outputAccumulationTimeout
while ContinuousClock.now < deadline {
let text = try await client.receiveText(timeout: SpikeTunables.frameTimeout)
guard let frame = decodeFrame(text) else { continue }
switch frame.type {
case "attached":
adopted = frame.sessionId
case "output":
let data = frame.data ?? ""
total += data.utf8.count
// soft-reset \x1b[0msrc/types.ts:167-170
if !sawSoftReset, data.contains("\u{1B}[0m") { sawSoftReset = true }
default:
break
}
if let adopted, total >= minBytes {
return .success(ReplaySummary(
adoptedSessionId: adopted, totalOutputBytes: total, containsSoftReset: sawSoftReset))
}
}
return .failure(SpikeError.timeout(
"回放不完整: attached=\(adopted ?? "") bytes=\(total)/\(minBytes)"))
} catch {
return .failure(error)
}
}
/// maximumMessageSize NSPOSIXErrorDomain 40Darwin errno 40 =
/// EMSGSIZE "Message too long"plan §1 "ENOBUFS(40)"Darwin ENOBUFS
/// 55
private func isMessageTooLongError(_ error: any Error) -> Bool {
func matches(_ ns: NSError) -> Bool {
ns.domain == NSPOSIXErrorDomain && (ns.code == Int(EMSGSIZE) || ns.code == Int(ENOBUFS))
}
let ns = error as NSError
if matches(ns) { return true }
if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError { return matches(underlying) }
return false
}
private func describeError(_ error: any Error) -> String {
let ns = error as NSError
let underlying = (ns.userInfo[NSUnderlyingErrorKey] as? NSError)
.map { " underlying=\($0.domain)#\($0.code)" } ?? ""
return "\(ns.domain)#\(ns.code)\(underlying): \(ns.localizedDescription)"
}
/// DELETE /live-sessions/:id G Origin403
/// src/server.ts:332-339宿 bash
///
private func killSession(server: SpikeServer, id: String) async {
var request = URLRequest(url: server.baseURL.appending(path: "live-sessions/\(id)"))
request.httpMethod = "DELETE"
request.setValue(server.origin, forHTTPHeaderField: "Origin")
_ = try? await URLSession.shared.data(for: request)
}
// Spike
@Suite("T-iOS-2 Day-1 spike:URLSessionWebSocketTask 对真服务器", .serialized, .timeLimit(.minutes(5)))
struct OriginSpikeTests {
@Test("spike①: 无 Origin 的 WS 升级被 401 拒绝(服务器默认拒空 Origin)")
func upgradeWithoutOriginIsRejectedWith401() async throws {
let server = try await ServerHarness.shared.ensureServer()
let client = SpikeWSClient(server: server, origin: nil, maxMessageBytes: nil)
defer { client.close() }
let failure = await client.handshakeFailure(timeout: SpikeTunables.frameTimeout)
#expect(failure != nil, "无 Origin 升级必须失败")
#expect(client.handshakeStatusCode == 401,
"应收 401(src/server.ts:646-651),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")")
}
@Test("spike②: 自定义 Origin 精确匹配 → 升级成功,attach(null) 收到 attached")
func customOriginHeaderIsSentAndAttachSucceeds() async throws {
let server = try await ServerHarness.shared.ensureServer()
let client = SpikeWSClient(
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
defer { client.close() }
let sessionId = try await attachAndAwaitAttached(client: client, sessionId: nil)
#expect(UUID(uuidString: sessionId) != nil, "attached.sessionId 应为 UUID,got \(sessionId)")
#expect(client.handshakeStatusCode == 101,
"升级应 101,实际 \(String(describing: client.handshakeStatusCode))")
client.close()
await killSession(server: server, id: sessionId)
}
@Test("spike③: >1MiB 单帧回放 — 默认 1MiB 上限复现失败(known issue);16MiB 成功")
func replayOver1MiBFailsAtDefaultLimitAndSucceedsAt16MiB() async throws {
let server = try await ServerHarness.shared.ensureServer()
// :attach(null) >1MiB , detach(PTY/ring )
let generator = SpikeWSClient(
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil)
try await generator.sendFrame(
inputFrame(command: "perl -e 'print \"a\" x \(SpikeTunables.bulkOutputBytes)'"))
let produced = try await accumulateOutput(
client: generator, minBytes: SpikeTunables.bulkOutputBytes)
#expect(produced >= SpikeTunables.bulkOutputBytes)
generator.close()
// : maximumMessageSize(1 MiB)
let defaultClient = SpikeWSClient(server: server, origin: server.origin, maxMessageBytes: nil)
let defaultResult = await replayResult(
client: defaultClient, sessionId: sessionId, minBytes: SpikeTunables.bulkOutputBytes)
defaultClient.close()
if case .failure(let error) = defaultResult {
#expect(isMessageTooLongError(error),
"失败模式应为 NSPOSIXErrorDomain EMSGSIZE(40)/ENOBUFS(55),实际 \(describeError(error))")
}
withKnownIssue("默认 maximumMessageSize=1MiB 收不下 >1MiB 单帧 ring-buffer 回放 — 平台限制,修法即 Tunables.maxWSMessageBytes=16MiB(若此处'意外通过',说明平台行为变了,须重新评估)") {
_ = try defaultResult.get()
}
// :16 MiB ,attached sessionId
let bigClient = SpikeWSClient(
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
let summary = try await replayResult(
client: bigClient, sessionId: sessionId, minBytes: SpikeTunables.bulkOutputBytes).get()
#expect(summary.adoptedSessionId == sessionId)
#expect(summary.totalOutputBytes >= SpikeTunables.bulkOutputBytes)
bigClient.close()
await killSession(server: server, id: sessionId)
}
@Test("spike③-对抗: ESC/C0 密集输出(JSON 转义膨胀~6×)回放,16MiB 仍成功")
func escDenseReplayStillSucceedsAt16MiB() async throws {
let server = try await ServerHarness.shared.ensureServer()
let generator = SpikeWSClient(
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil)
// perl \e = ESC(0x1B): 4 JSON 9 ("")
try await generator.sendFrame(
inputFrame(command: "perl -e 'print \"\\e[0m\" x \(SpikeTunables.escSequenceRepeats)'"))
let produced = try await accumulateOutput(
client: generator, minBytes: SpikeTunables.escSequenceRawBytes)
#expect(produced >= SpikeTunables.escSequenceRawBytes)
generator.close()
let bigClient = SpikeWSClient(
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
let summary = try await replayResult(
client: bigClient, sessionId: sessionId, minBytes: SpikeTunables.escSequenceRawBytes).get()
#expect(summary.adoptedSessionId == sessionId)
#expect(summary.totalOutputBytes >= SpikeTunables.escSequenceRawBytes)
#expect(summary.containsSoftReset, "回放应含 ESC[0m(转义序列在回放中原样保留)")
bigClient.close()
await killSession(server: server, id: sessionId)
}
@Test("spike④: 端口失配的 Origin → 401(端口精确比对;默认端口规范化不是失配案例)")
func portMismatchedOriginIsRejectedWith401() async throws {
let server = try await ServerHarness.shared.ensureServer()
let mismatchPort = server.port == SpikeTunables.mismatchedOriginPort
? SpikeTunables.mismatchedOriginPortFallback
: SpikeTunables.mismatchedOriginPort
// ::443/:80 scheme new URL()
// (src/http/origin.ts:31-51),;
let badOrigin = "http://127.0.0.1:\(mismatchPort)"
let client = SpikeWSClient(server: server, origin: badOrigin, maxMessageBytes: nil)
defer { client.close() }
let failure = await client.handshakeFailure(timeout: SpikeTunables.frameTimeout)
#expect(failure != nil, "端口失配 Origin 升级必须失败")
#expect(client.handshakeStatusCode == 401,
"应收 401(src/http/origin.ts:47-51),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")")
}
}