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
This commit is contained in:
Yaojia Wang
2026-07-04 21:19:30 +02:00
parent 9b41ffa574
commit cbaa08daba
46 changed files with 3203 additions and 3 deletions

View File

@@ -97,7 +97,7 @@
**为什么 SwiftTerm 而不是 WKWebView + xterm.js**:原生渲染、原生手势与选择、软键盘/IME 全走系统栈;`TerminalViewDelegate.send(source:data:)` / `sizeChanged` / `feed(byteArray:)``input`/`resize`/`output` 帧 1:1。WKWebView 方案两位评审均否决(见 §0 不做。SwiftTerm 历史上最麻烦的是自绘文本选择与 first-responder——Day-1 spike + 验收里专门压这块。
**为什么 URLSessionWebSocketTask 而不是 Starscream**系统框架、iOS 13+ 内建Starscream 最后一版 4.0.8 已 ~2 年未动,仅作 spike 失败时的备胎。三个已知坑在 P0 直接立规矩:**`maximumMessageSize` 默认 1 MiB而 ring-buffer 回放是单帧全量(`buffer.snapshot()`src/session/session.ts:165-171JSON 会把控制字节 `\uXXXX` 转义膨胀 16×src/protocol.ts:186最坏 ≈ 6 × SCROLLBACK_BYTES默认 2 MiB——必须设 `Tunables.maxWSMessageBytes = 16 MiB`(≥ 6×默认 + 帧包络)**;即便如此 SCROLLBACK_BYTES 是服务器 env 可调、客户端运行时无法得知(协议无 config 握手),**超限时 `receive()` 以 NSPOSIXErrorDomain code 40ENOBUFS"Message too long")失败——必须归类为不可重试的 `connection(.failed(.replayTooLarge))` 显式错误态并给可操作话术("服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"),绝不喂进 backoff 重连循环**(否则确定性无限重试);**`receive()` 一次只交付一条消息,必须循环 re-arm**,忘了就静默断流;**没有自动 ping25 s 定时 `sendPing`**`PingScheduler`+ 显式 "reconnecting…" 横幅,终端绝不"看着连着其实死了"。
**为什么 URLSessionWebSocketTask 而不是 Starscream**系统框架、iOS 13+ 内建Starscream 最后一版 4.0.8 已 ~2 年未动,仅作 spike 失败时的备胎。三个已知坑在 P0 直接立规矩:**`maximumMessageSize` 默认 1 MiB而 ring-buffer 回放是单帧全量(`buffer.snapshot()`src/session/session.ts:165-171JSON 会把控制字节 `\uXXXX` 转义膨胀 16×src/protocol.ts:186最坏 ≈ 6 × SCROLLBACK_BYTES默认 2 MiB——必须设 `Tunables.maxWSMessageBytes = 16 MiB`(≥ 6×默认 + 帧包络)**;即便如此 SCROLLBACK_BYTES 是服务器 env 可调、客户端运行时无法得知(协议无 config 握手),**超限时 `receive()` 以 NSPOSIXErrorDomain code 40EMSGSIZE"Message too long"T-iOS-2 spike 实测勘误——原文误标 ENOBUFSDarwin ENOBUFS=55归类以 40 为主、55 兜底)失败——必须归类为不可重试的 `connection(.failed(.replayTooLarge))` 显式错误态并给可操作话术("服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"),绝不喂进 backoff 重连循环**(否则确定性无限重试);**`receive()` 一次只交付一条消息,必须循环 re-arm**,忘了就静默断流;**没有自动 ping25 s 定时 `sendPing`**`PingScheduler`+ 显式 "reconnecting…" 横幅,终端绝不"看着连着其实死了"。
**为什么 4 个纯 SwiftPM 包 + 薄 App 胶水**:逻辑全部下沉到无 UIKit 依赖的包里(`WireProtocol`/`SessionCore`/`HostRegistry`/`APIClient``swift test` 秒级跑、80% 覆盖率门只量"值得量的逻辑"App target 只剩 UIViewRepresentable、导航和 ViewModel 粘合。依赖方向**严格单向向下**`WireProtocol` 是唯一冻结契约(对应 `src/types.ts` 的地位)——**共享 I/O 边界类型也在这里**`HostEndpoint`/`TermTransport`/`TransportConnection`/`HTTPTransport`/`TimelineEvent`/`Tunables`,见 §3.1SessionCore/HostRegistry/APIClient/TestSupport 全部只 import WireProtocol叶子包之间零耦合W0 的 TestSupport 就能编译。
@@ -266,7 +266,7 @@ public actor SessionEngine { // 每个"打开中的会话"一
public enum SessionEvent: Sendable, Equatable {
case connection(ConnectionState) // .connecting/.connected/.reconnecting(attempt:next:)/.closed
// /.failed(FailureReason) —— 不可重试终态(如 .replayTooLarge:
// receive() ENOBUFS/message-too-big,绝不进 backoff 重连,UI 给可操作话术)
// receive() EMSGSIZE(40)/message-too-big,绝不进 backoff 重连,UI 给可操作话术)
case adopted(sessionId: UUID) // attached 帧;未知 UUID 会拿到新 id —— 永远采用它
case output(String) // 直接 feed 给 SwiftTerm(@MainActor hop 由 VM 负责)
case exited(code: Int, reason: String?)
@@ -601,7 +601,7 @@ W5 验收(report-only, 并行)
- [ ] `maximumMessageSize == Tunables.maxWSMessageBytes`16 MiB——直接断言 task 配置
- [ ] receive 循环持续 re-arm连发 100 帧全部到达、顺序不乱
- [ ] 服务器关闭close frame→ frames stream finish错误 → stream throw两种可区分
- [ ] receive 失败 NSPOSIXErrorDomain code 40ENOBUFS"Message too long"——超 `maximumMessageSize` 在 iOS 上**不是** 1009 干净关闭)→ stream throw **类型化 `.replayTooLarge` 错误**(供 engine 识别为不可重试)
- [ ] receive 失败 NSPOSIXErrorDomain code 40EMSGSIZE"Message too long"——T-iOS-2 spike 实测;原文 ENOBUFS 为笔误55(ENOBUFS) 作兜底同判——`maximumMessageSize` 在 iOS 上**不是** 1009 干净关闭)→ stream throw **类型化 `.replayTooLarge` 错误**(供 engine 识别为不可重试)
- [ ] `close()` 后 send → 显式错误,不 crash
- [ ] 非文本binary帧 → 丢弃并继续收(服务器只发文本帧,但不信任它)
- **Steps(实现, GREEN)**: [ ] `URLSessionWebSocketDelegate`didOpen/didClose 驱动状态,不靠 receive error 猜)[ ] ping 由 PingScheduler 注入驱动