diff --git a/.agent/HANDOFF.md b/.agent/HANDOFF.md index 1517b33..b431e7c 100644 --- a/.agent/HANDOFF.md +++ b/.agent/HANDOFF.md @@ -10,7 +10,8 @@ v1 当前包含: - `OneShotEvent`、`AsyncManualResetEvent`、RAII `AsyncMutex`; - 带定时和 `std::stop_token` 取消的调用线程 `RunLoop`; - 固定大小 `ThreadPool` 与结构化同步调用隔离 `run_blocking()`; -- 单私有 I/O driver 的 `IoContext`,以及数值地址 TCP client `TcpStream`。 +- 单私有 I/O driver 的 `IoContext`、数值地址 TCP client `TcpStream`,以及数值地址 + TCP server `TcpListener`。 项目固定 mcpp `2026.8.28.1`、LLVM `22.1.8`、Asio `1.38.1` 与 gtest `1.15.2`。 三平台 CI 引导 xlings 固定为 `2026.8.27.5`。`examples/basic` 是独立 path-dependency @@ -18,93 +19,170 @@ consumer。 ## 当前目标与状态 -Phase 4、Phase 5、Phase 6A 和 Phase 6B 第 1–10 项均已本地完成。当前分支为 -`feature/phase4-v1-readiness`,PR #10 已创建: +PR #10 已合并,`main` 已同步到 squash commit `9492075`。当前分支为 +`feature/phase6c-tcp-server-v1`。Phase 6C TCP Server v1 已通过全部本地门禁,核心实现提交 +为 `e0248ca`(`实现协程 TCP 服务端`),当前分支已推送至 `origin`。 - +Phase 7 是最后一个已规划的 v1 阶段,只做发布验收,不增加运行时 API。它的 Design/Plan +已完成复核,并已执行不依赖远程权限的早期包转换预检;正式实施仍等待 Phase 6C 合并后 +切换到独立分支。 -PR 初次检查中,macOS arm64 与 Windows x86_64 已通过;Linux x86_64 在编译 CMP 前因 -mcpp/xlings 冷缓存运行时绑定失配失败。修复提交 `7c81a10` 已推送;随后 Linux、macOS、 -Windows 三项 CI 全部通过。状态文档提交 `4a909ed` 推送后的第二轮三平台 CI 也全部通过, -Phase 5 与 Phase 6B 的本地及远程交付门均已满足。PR #10 保持打开;用户未授权合并。 +用户已授权当前持续目标内的 Git commit 与 push。Phase 6C PR #11 已创建,Linux、macOS、 +Windows CI 均通过;合并仍需用户另行明确授权。 ## 已完成工作 -- Phase 4 补齐定时、取消、结构化并发、事件和互斥原语。 -- Phase 5 实现固定大小多 worker `ThreadPool` 并完成基准验证。 -- Phase 6A 实现显式 blocking worker 与返回 Scheduler 的 `run_blocking()`。 -- Phase 6B 实现一个跨平台原生异步 TCP client 切片:numeric connect、partial read、 - write-all、单读单写并发、取消、关闭、context drain/join、return affinity 和 exactly-once - 完成;未加入 DNS、server、TLS、UDP、文件 I/O、timeout 或隐式全局 executor。 -- 当前共有 11 个公开模块分区、10 个测试文件和 140 项测试。 -- readiness benchmark 的计算、文件 I/O 与网络回环场景均有成功/预期失败硬计数;网络 client - 使用 `IoContext`/`TcpStream`,同步 server 通过 `run_blocking()` 隔离。 -- 审查全部 13 份既有 Spec/Plan 后,已纠正早期状态;Phase 6B 本地任务全部完成。 -- 创建 PR #10 并取得首轮远程结果:macOS、Windows 成功;Linux 在 `Build library` 前置 - 工具链初始化失败,CMP 源码与测试未开始执行。 -- 根因是 mcpp `2026.8.11.2` 携带的旧 xlings 声明 `glibc@2.44`,而当前索引安装 - `2.44.2`。官方 mcpp `2026.8.28.1` 固定 xlings `2026.8.27.5` 并包含版本精化兼容路径。 -- 自审时纠正“只提升 mcpp”的初稿:Linux 日志证明 mcpp 冷启动会复制 workflow 的系统 - xlings,因此同时提升 `.xlings.json` 与三个 workflow pin,其他步骤不变。 -- 提交并推送 `7c81a10 修复 CI 冷缓存工具链`;PR #10 的三个冷环境均安装 xlings - `2026.8.27.5` 和 mcpp `2026.8.28.1`,完成构建、10 个测试二进制与独立示例。 +- 在现有 `mcpplibs.cmp:tcp` 分区增加 move-only `TcpListener`: + - `bind(IoContext&, Scheduler, numericAddress, port, stop_token)`; + - `accept(Scheduler, stop_token)`; + - `close()`、`is_open()`、`local_port()`。 +- bind/accept 保持懒执行;每个成功或错误都尝试通过调用方选择的返回 Scheduler 发布。 +- 支持数值 IPv4/IPv6、端口零、系统最大 backlog、一个 pending accept、取消后复用、 + 线程安全幂等 close,以及 listener 与已接受 `TcpStream` 的独立所有权。 +- `IoContext` 增加私有 listener 弱引用 registry;shutdown 先关闭 listener,再关闭 + stream,并排空 native handler 后 join driver。 +- accept 候选 socket 在 native initiation 前登记,并在返回 Scheduler 后复核待发布资源; + 自审同时纠正 bind/connect/accept 的 context shutdown 发布窗口,关闭获胜时返回 + `OperationCancelled`,不会发布已关闭对象。 +- read/write/accept 的 pending 标志是唯一重叠门禁;driver 可覆盖已完成 operation 的弱引用, + 不再错误依赖上一 Task 帧已经销毁。 +- 增加 21 项 Phase 6C TCP 测试;`tcp_test` 现为 45 项,完整套件为 10 个二进制、161 项。 +- readiness 的正常 TCP server 已从阻塞 POSIX socket 迁移到 + `TcpListener`/`TcpStream`;POSIX 只保留未监听失败端口构造。 +- 三种 README、三种架构文档、Phase 6C Design/Plan 和 readiness 报告已同步。 +- 新增并复核 Phase 7 v1 发布验收 Design/Plan,明确三平台双配置 CI、独立 consumer、 + 源码归档、mcpp-index Form A、GitHub Release 与各远程授权边界;Phase 7 后不自动规划 + Phase 8。 +- 预检纠正两项 mcpp `2026.8.28.1` 行为假设:生成的描述符不能直接作为 index 文件, + `--allow-dirty` 也不会把未提交工作树内容装入源码归档。 ## 重要决策 -- 只修改有证据的工具版本:mcpp `2026.8.28.1`、CI xlings `2026.8.27.5`;不升级到包含 - 无关构建规则变化的 mcpp `2026.8.29.1`。 -- 不用固定旧 glibc、增加缓存或手工安装载荷来隐藏冷缓存失配。 -- LLVM、Asio、gtest、CMP API/源码和 workflow 执行流程保持不变。 -- Asio 仅作私有三平台 socket backend,公共 API 不暴露 Asio 类型。 -- `IoContext` v1 保持单 driver;只有基准证明瓶颈后才考虑多 driver/strand。 -- `run_blocking()` 隔离同步调用,但不宣称可抢占,也不替代原生异步 I/O。 +- 只提供数值地址 bind;不隐藏阻塞 DNS。 +- port 0 交由系统选择,`local_port()` 返回实际端口;close 后保留最后绑定端口, + moved-from 返回 0。 +- v1 使用系统最大 backlog,不隐式设置 `reuse_address`,不开放任意 socket option。 +- 同一 listener 只允许一个 accepted-but-not-yet-published accept;连续服务由普通 accept + 循环与结构化 handler 组合。 +- pre/active accept cancellation 保持 listener 可用;listener close 或 context shutdown + 获胜时 pending accept 恰好以 `OperationCancelled` 完成一次。 +- 继续使用一个私有 I/O driver 和两类线性弱引用 registry;只有基准证明瓶颈后才扩展。 +- 不增加 DNS、TLS、UDP、endpoint 类型、超时、连接配额、detached handler、通用 I/O + 抽象或新依赖。 + +## 路线完整性审计 + +- 从 bootstrap、RunLoop、timer、cancellation、`when_all`、TaskGroup、events、AsyncMutex, + 到 Phase 4–7 及 CI 冷缓存修复,共有 16 组逻辑 Design/Plan;已逐一核对,全部成对存在。 +- Phase 4 已完成 v1 基础契约和 readiness;Phase 5 已完成 ThreadPool;Phase 6A 已完成 + blocking offload;Phase 6B 已完成 TCP client,并由 PR #10 三平台验证。 +- Phase 6C TCP server 的本地实现、161 项门禁和 PR #11 三平台 CI 均已完成,只待授权合并。 +- Phase 7 是最后一个计划阶段,仅做发布资格、三平台双配置、干净归档、GitHub Release 和 + mcpp-index 验收。没有遗漏的已规划 Phase 8;未来能力必须由真实需求启动独立 Design。 ## 修改 / 重要文件 -- 工具环境:`.xlings.json` -- CI:`.github/workflows/ci-linux.yml`、`ci-macos.yml`、`ci-windows.yml` -- 当前修复设计: - `docs/superpowers/specs/2026-08-30-cmp-ci-cold-cache-toolchain-design.md` -- 当前修复计划: - `docs/superpowers/plans/2026-08-30-cmp-ci-cold-cache-toolchain.md` -- Phase 6B:`src/tcp.cppm`、`tests/tcp_test.cpp`、 - `docs/superpowers/specs/2026-08-29-cmp-phase6b-tcp-client-v1-design.md`、 - `docs/superpowers/plans/2026-08-30-cmp-phase6b-tcp-client-v1.md` +- 实现:`src/tcp.cppm` +- 测试:`tests/tcp_test.cpp` +- readiness consumer:`benchmarks/v1-readiness/src/main.cpp` +- readiness 数据:`docs/benchmarks/2026-08-29-cmp-v1-readiness.md` +- Design: + `docs/superpowers/specs/2026-08-30-cmp-phase6c-tcp-server-v1-design.md` +- Plan: + `docs/superpowers/plans/2026-08-30-cmp-phase6c-tcp-server-v1.md` +- Phase 7 Design: + `docs/superpowers/specs/2026-08-30-cmp-phase7-v1-release-qualification-design.md` +- Phase 7 Plan: + `docs/superpowers/plans/2026-08-30-cmp-phase7-v1-release-qualification.md` +- 用户文档:`README.md`、`README.zh.md`、`README.zh.hant.md` +- 架构文档:`docs/architecture.md`、`docs/architecture.zh.md`、 + `docs/architecture.zh.hant.md` - 当前状态:`.agent/HANDOFF.md` ## 验证情况 -- `xlings update && xlings install -y`:成功;`mcpp --version` 为 `2026.8.28.1`。 - `mcpp build --profile dev --strict --cache=off`:通过,LLVM `22.1.8`。 -- `mcpp test --profile dev --strict --cache=off`:10 个二进制、140/140 通过。 +- `mcpp test --profile dev --strict --cache=off`:10 个二进制、161/161 通过。 - `mcpp build --profile release --strict --cache=off`:通过。 -- `mcpp test --profile release --strict --cache=off`:10 个二进制、140/140 通过。 -- `examples/basic` 的 `mcpp run`:通过,全部十行既有示例输出正确,退出码 0。 -- Phase 6B 最终本地竞态门禁:100 次 close/completion 与 500 次 stop/completion 无丢失或 - 重复完成。 -- readiness Release 五轮共 15 行场景全部 PASS,非预期失败为 0;网络中位数 - 2,078.127 ms / 9,672.2 ops/s。 -- PR #10 首轮 CI:macOS、Windows 通过;Linux 因旧工具冷缓存失配失败,CMP 未开始编译。 -- PR #10 修复后 CI:Linux x86_64、macOS arm64、Windows x86_64 全部通过,耗时分别为 - 2m32s、2m42s、2m10s;各平台均为 10 个测试二进制、0 个失败,示例输出成功。 -- PR #10 状态文档提交 `4a909ed` 后再次全绿:Linux、macOS、Windows 分别为 2m34s、 - 2m49s、2m10s;代码与工具配置未再变化。 +- `mcpp test --profile release --strict --cache=off`:10 个二进制、161/161 通过。 +- `mcpp build --profile dist --strict --cache=off`:通过。 +- focused Release 发布窗口门禁连续 20 轮通过:bind、connect、accept 在返回 Scheduler + 阻塞期间遇到 context shutdown 均返回取消,未发布已关闭资源。 +- focused Release 原生 operation 复用门禁连续 5 轮、25 项执行全部通过,覆盖 + read/write/accept 的重叠拒绝与取消后复用。 +- Release `tcp_test` 使用随机顺序 seed `60830` 运行,45/45 通过,未发现用例顺序依赖。 +- 静态可移植性扫描确认 `src/`、`tests/`、`examples/` 未引入平台 socket 头或全局 + POSIX socket 调用;此类调用只存在于明确标注 POSIX-only 的 readiness consumer。 +- 基于 mcpp 生成的 26 条模块编译数据库运行 `clang-analyzer-*` 检查 + `src/tcp.cppm` 与 `tests/tcp_test.cpp`:两者退出码均为 0、无诊断。 +- stream pending read 与 listener pending accept 的并发 close 门禁在 Release 连续 100 轮 + 通过:合计 800 次并发 `close()` 均保持幂等,两类操作每轮都只取消完成一次。 +- focused Release listener 竞态连续 5 轮通过:共覆盖 100 次 close/accept 与 500 次 + stop/accept,均恰好产生一个合法结果。 +- focused Release 生命周期门禁连续 20 轮、共 100 项执行全部通过,覆盖 ThreadPool 返回、 + 失效返回 Scheduler、主动取消后复用、context shutdown 与 32 客户端负载。 +- 额外尝试的 Release TCP 全套 50 轮压力并未作为通过项:前 11 轮 43/43 全通过,随后本机 + 仅 `60700-61000` 的临时端口范围被短连接 TIME_WAIT 占满,port-0 bind 开始返回 + `Address already in use`;现场有 2,333 个 TIME_WAIT。未修改系统参数,也未让库隐式开启 + `reuse_address` 来掩盖资源耗尽。 +- TIME_WAIT 自然回落到 2 后,正常单轮 Release TCP 套件立即恢复为 43/43 通过,确认该失败 + 来自宿主端口资源压力,而非持续 socket 泄漏或实现状态损坏。 +- `examples/basic` 的 `mcpp run`:通过,既有十行输出正确,退出码 0。 +- `examples/basic` 的 `dist` 构建及生成二进制运行:通过,同样输出正确十行,退出码 0。 +- readiness Release 严格无缓存构建通过;最终 5 轮共 15 行场景全部 PASS,所有轮次硬计数均为: + - compute:49,000 成功 / 1,000 预期失败 / 0 非预期失败; + - file I/O:1,000 成功 / 100 预期失败 / 0 非预期失败; + - network:20,000 成功 / 100 预期失败 / 0 非预期失败。 +- 最终中位数:compute `25.443 ms / 1,965,140.8 ops/s`,file I/O + `147.661 ms / 7,449.5 ops/s`,native async TCP client/server + `2,239.147 ms / 8,976.6 ops/s`。 +- Phase 6C 当前工作树再次执行 Dev/Release 严格无缓存构建与测试,均为 10 个二进制、 + 161/161 通过;`src/tcp.cppm` 与 `tests/tcp_test.cpp` 的 `clang-analyzer-*` 再次退出 0、 + 无诊断;独立 example 再次正确输出十行并退出 0。 +- 当前 readiness 二进制重新连续运行 5 轮,所有轮次仍保持 compute + `49,000/1,000/0`、file I/O `1,000/100/0`、network `20,000/100/0` 的 + 成功/预期失败/非预期失败硬计数。 +- `mcpp publish --dry-run --allow-dirty` 成功生成早期归档 + `cmp-0.1.0.tar.gz`(184,046 bytes,SHA-256 + `08e467dfe45cfdddde3664be7223995b56c7e172a6c6e568ce7ae9f3da4db062`)与描述符。 + 该 SHA 只属于当前已提交基线,Phase 6C 合并后的最终归档必须重新生成。 +- 生成描述符直接解析会因内联 `mcpp` 段缺少 `sources` 失败;临时候选改为短包名 + `cmp` 并移除内联 `mcpp` 段后,`mcpp xpkg parse` 明确报告 Form A、三平台 + `0.1.0`、`parse OK`。 +- 早期源码归档不含 `TcpListener`,确认 `--allow-dirty` 未装入 Phase 6C。归档对应的 + Phase 6B 基线使用已安装的 mcpp `2026.8.28.1` 在隔离目录完成 Dev/Release 构建, + 两套均为 10 个测试二进制、140/140 通过;归档内 example 的运行和 Release 构建也通过。 +- PR #11 的 Linux x86_64、macOS arm64、Windows x86_64 检查均通过;首轮耗时分别为 + 2m43s、2m45s、2m49s。 ## 已知问题 / 风险 -- 本机 `~/.mcpp` 仍保存旧 xlings `2026.8.11.2` 且缺少 `subos_info`;mcpp - `2026.8.28.1` 会提示 runtime facts inconclusive,但本地 LLVM 双配置构建、测试与示例 - 均成功。没有为本次验证修改用户的全局 xlings;远程 workflow 会在冷环境直接安装 - `2026.8.27.5`。 -- 运行中的 blocking callable 不可抢占;永久阻塞会占用 worker。 -- ThreadPool 是无界共享 FIFO;应用需要限制持续生产时的结构化 in-flight 数量。 -- 单 I/O driver 是 v1 简化;尚无基准证据要求扩展。 +- 本机 `~/.mcpp` 仍保存旧 vendored xlings `2026.8.11.2` 且缺少 `subos_info`; + mcpp 提示 runtime facts inconclusive,但当前 LLVM 双配置构建、测试、示例和 benchmark + 均成功。本阶段未修改用户全局环境。 +- 单 I/O driver 是 v1 简化;当前 readiness 只提供本机回归数据,不是 SLA。 +- 本机 IPv4 临时端口范围只有 301 个端口;不要连续重复完整 TCP 套件 50 轮。当前 5 轮竞态与 + 20 轮定向生命周期门禁是避免耗尽宿主资源的有界压力门。 +- bind 取消只在 driver setup 开始前协作生效;短同步 open/bind/listen 开始后由完成获胜。 +- 内置 RunLoop/ThreadPool 返回 Scheduler 会离开 I/O driver;自定义 Scheduler 必须自行提供 + 所需的线程转移语义,inline 实现不得在 driver continuation 中析构 `IoContext`。 +- listener 不提供 DNS、TLS、backlog 配置、socket option、多 pending accept 或自动 handler + 所有权;这些都需要具体 consumer 与独立设计。 +- 运行中的 blocking callable 仍不可抢占;ThreadPool 仍为无界共享 FIFO。 +- 离开项目本地 `.xlings` 环境后,普通 xlings shim 目前只激活 mcpp `2026.8.11.2`, + 无法解析 `.xlings.json` 要求的 `2026.8.28.1`;已安装的 `2026.8.28.1` 实际二进制可用, + 归档隔离构建用它完成。不要把本地 `.xlings` 目录塞进发布包;正式 CI/index consumer + 应按固定版本安装工具。 +- mcpp `2026.8.28.1` 自动描述符的内联 `mcpp` 段缺少 `sources`,且生成名是兼容全名; + index 候选必须规范化为短名 `cmp` 的 Form A。没有真实 CN 镜像时保留普通字符串 URL。 ## 剩余工作 -1. 当前没有未完成的 v1 开发或验证任务。 -2. PR #10 尚未合并;合并需要用户另行明确授权。 +1. 用户审查三平台全绿的 PR #11,并明确授权后再合并。 +2. 合并后同步 `main`、新建 Phase 7 分支,按 Design/Plan 加强三平台 Dev/Release CI, + 并从干净候选重跑 161 项、example、readiness、归档及 Form A 验收。 +3. tag、GitHub Release、mcpp-index PR 与 index 合并均是独立远程操作,逐项等待授权。 ## 推荐下一步 -等待用户审查 PR #10,并决定是否授权合并或开始下一阶段;未经授权不合并。 +由用户审查 PR #11 并确认是否授权合并;在此之前不执行 merge。 diff --git a/README.md b/README.md index 0fcc5c4..57b8b20 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,9 @@ > CMP provides a lazy, single-consumer `Task` / `Task`, structured variadic and vector > `when_all()`, an eager structured `TaskGroup`, one-shot and reusable events, an RAII `AsyncMutex`, > a caller-thread `RunLoop` with explicit and monotonic timed scheduling, and a fixed-size CPU -> `ThreadPool`. Phase 6B adds an explicit `IoContext` and move-only `TcpStream` for native async -> numeric-address TCP clients. `run_blocking()` executes an owned synchronous callable on a -> dedicated pool instance +> `ThreadPool`. Phase 6B/6C add an explicit `IoContext`, move-only `TcpStream`, and move-only +> `TcpListener` for native async numeric-address TCP clients and servers. `run_blocking()` executes +> an owned synchronous callable on a dedicated pool instance > and delivers its outcome through an explicit return Scheduler. Ready scheduling on either > executor, timed waits, reusable-event waits, TaskGroup children, and queued blocking offloads can > use explicit cooperative cancellation with `std::stop_token`; TCP operations use the same token @@ -32,8 +32,8 @@ CMP is being built as a modern coroutine runtime and library on standard stackless C++ coroutines. Its explicit `co_await` model now covers fixed and incremental structured concurrency, one-time event notification, caller-thread and multi-worker scheduling, monotonic timers, and -cancellable waits, structured isolation of blocking work, and one portable native async TCP client -slice. +cancellable waits, structured isolation of blocking work, and portable native async TCP client and +listener slices. ## Why CMP? @@ -61,8 +61,8 @@ promise that: - task migration is implicit, work stealing is already enabled, or every I/O family is async. Those capabilities must be designed and verified individually. CMP now uses an explicitly -dedicated `ThreadPool` instance with `run_blocking()` for synchronous work. Phase 6B provides native -async TCP clients; DNS, listening sockets, TLS, file I/O, and cooperative safe points remain +dedicated `ThreadPool` instance with `run_blocking()` for synchronous work. Phase 6B/6C provide +native async TCP clients and listeners; DNS, TLS, file I/O, and cooperative safe points remain separate work. ## Quick Start @@ -103,6 +103,7 @@ using mcpplibs::cmp::OneShotEvent; using mcpplibs::cmp::TaskGroup; using mcpplibs::cmp::ThreadPool; using mcpplibs::cmp::IoContext; +using mcpplibs::cmp::TcpListener; using mcpplibs::cmp::TcpStream; using mcpplibs::cmp::run_blocking; using mcpplibs::cmp::when_all; @@ -343,6 +344,16 @@ Task exchange( co_await stream.write_all(caller, request, token); co_return co_await stream.read_some(caller, reply, token); } + +Task accept_one( + IoContext& io, + RunLoop::Scheduler caller, + std::uint16_t port, + std::stop_token token = {}) { + auto listener = co_await TcpListener::bind( + io, caller, "127.0.0.1", port, token); + co_return co_await listener.accept(caller, token); +} ``` Share one immovable `IoContext` across streams; it owns one private I/O driver. `TcpStream` is @@ -351,9 +362,12 @@ Read/write spans borrow their storage until the returned Task completes. Every s attempts the explicit return-Scheduler hop. One read and one write may coexist; another operation in the same direction throws `std::logic_error`. Pre-cancellation leaves an open stream usable, while cancellation after `write_all()` starts closes it. `close()` is thread-safe, idempotent, and -causes accepted operations to complete once with `OperationCancelled` when close wins. Phase 6A -isolates arbitrary synchronous calls on ThreadPool workers; Phase 6B is native async TCP, not a -generic async-I/O layer. +causes accepted operations to complete once with `OperationCancelled` when close wins. +`TcpListener::bind()` performs numeric bind-and-listen, accepts port zero, and exposes the selected +port through `local_port()`. One accept may be pending; pre/active accept cancellation leaves the +listener usable, while close cancels the pending accept. Accepted streams are independent from the +listener. Phase 6A isolates arbitrary synchronous calls on ThreadPool workers; Phase 6B/6C are +native async TCP, not a generic async-I/O layer. RunLoop is not a background thread and does not make blocking code asynchronous. A Task that suspends without arranging a future resume can leave `run()` waiting indefinitely. CMP does not @@ -372,7 +386,7 @@ await the desired Scheduler to return to its RunLoop. ├── src/run_loop.cppm # RunLoop and Scheduler partition ├── src/thread_pool.cppm # fixed-size CPU worker scheduler ├── src/blocking.cppm # structured blocking-call offload -├── src/tcp.cppm # native async TCP client and I/O context +├── src/tcp.cppm # native async TCP client/server and I/O context ├── src/when_all.cppm # structured concurrent Task join ├── src/task_group.cppm # eager mutable structured Task scope ├── src/one_shot_event.cppm # allocation-free one-time notification @@ -416,7 +430,7 @@ global mcpp installation. CMP does not track `mcpp.lock`; `.gitignore` enforces that repository policy. Runtime dependencies belong in `[dependencies]`; gtest is declared explicitly under `[dev-dependencies.compat]`. -The current local suite contains 140 tests across ten binaries. The POSIX-only Release pressure +The current local suite contains 161 tests across ten binaries. The POSIX-only Release pressure consumer and its recorded success/failure data are documented in the [v1 readiness benchmark](docs/benchmarks/2026-08-29-cmp-v1-readiness.md). Multi-worker correctness and performance data are documented in the @@ -433,8 +447,8 @@ Runtime work is split into independently reviewable phases: OneShotEvent, AsyncManualResetEvent, and AsyncMutex — implemented and pressure-tested; 5. fixed-size multi-worker scheduling — implemented and benchmarked; work stealing remains gated by profiling evidence; -6. structured blocking offload and native async numeric-address TCP client — implemented and - verified by Linux, macOS, and Windows CI in PR #10. +6. structured blocking offload and native async numeric-address TCP client/listener — implemented; + the client was verified by PR #10 and the listener by PR #11 on Linux, macOS, and Windows. The remaining order is directional, not a promise that a listed feature is already implemented. diff --git a/README.zh.hant.md b/README.zh.hant.md index 7e35a74..e65babf 100644 --- a/README.zh.hant.md +++ b/README.zh.hant.md @@ -21,15 +21,16 @@ > CMP 已提供延遲啟動、單一消費者的 `Task` / `Task`、支援變參和 vector 的結構化 > `when_all()`、eager 結構化 `TaskGroup`、一次性與可複用事件、RAII `AsyncMutex`,以及在呼叫 > 執行緒運行、支援明確排程和單調時鐘定時排程的 `RunLoop`,以及固定大小的 CPU -> `ThreadPool`。Phase 6B 新增明確的 `IoContext` 和 move-only `TcpStream`,用於原生非同步的 -> 數值位址 TCP client。`run_blocking()` 可在專用的 pool 實例上執行同步 callable,並透過 +> `ThreadPool`。Phase 6B/6C 新增明確的 `IoContext`、move-only `TcpStream` 和 move-only +> `TcpListener`,用於原生非同步的數值位址 TCP client 與 server。`run_blocking()` 可在專用的 +> pool 實例上執行同步 callable,並透過 > 明確的返回 Scheduler 交付結果。兩種執行器的就緒排程、定時等待、可複用事件等待、TaskGroup > 子任務和排隊中的阻塞 offload 可明確使用基於 `std::stop_token` 的協作式取消;TCP 操作採用 > 相同 token 模型。detached 執行和其他原生 I/O 類型尚未實作。 CMP 計畫以標準無堆疊 C++ 協程建構現代協程執行期與函式庫。明確的 `co_await` 模型現已 涵蓋固定與增量結構化並行、一次性事件通知、呼叫執行緒與多 worker 排程、單調時鐘計時器、 -可取消等待和阻塞工作的結構化隔離,以及一個可攜式原生非同步 TCP client 切片。 +可取消等待和阻塞工作的結構化隔離,以及可攜式原生非同步 TCP client 與 listener 切片。 ## 為什麼叫 CMP? @@ -55,7 +56,7 @@ C++ 標準協程是語言機制,不是完整執行期。因此 CMP 不會宣 - task 會隱式遷移、work stealing 已啟用,或所有 I/O 類型都已非同步化。 這些能力必須分別設計和驗證。CMP 目前透過明確的專用 `ThreadPool` 實例和 -`run_blocking()` 隔離同步工作。Phase 6B 已提供原生非同步 TCP client;DNS、監聽 socket、 +`run_blocking()` 隔離同步工作。Phase 6B/6C 已提供原生非同步 TCP client 與 listener;DNS、 TLS、檔案 I/O 和協作式安全點仍需分別設計。 ## 快速開始 @@ -97,6 +98,7 @@ using mcpplibs::cmp::OneShotEvent; using mcpplibs::cmp::TaskGroup; using mcpplibs::cmp::ThreadPool; using mcpplibs::cmp::IoContext; +using mcpplibs::cmp::TcpListener; using mcpplibs::cmp::TcpStream; using mcpplibs::cmp::run_blocking; using mcpplibs::cmp::when_all; @@ -324,6 +326,16 @@ Task exchange( co_await stream.write_all(caller, request, token); co_return co_await stream.read_some(caller, reply, token); } + +Task accept_one( + IoContext& io, + RunLoop::Scheduler caller, + std::uint16_t port, + std::stop_token token = {}) { + auto listener = co_await TcpListener::bind( + io, caller, "127.0.0.1", port, token); + co_return co_await listener.accept(caller, token); +} ``` 一個不可移動的 `IoContext` 可供多個 stream 共用,並擁有一個私有 I/O driver。`TcpStream` 只能 @@ -331,8 +343,11 @@ Task exchange( 保持到返回的 Task 完成。每種成功或錯誤都會嘗試明確返回 Scheduler。一個 read 和一個 write 可以並存;同方向第二個操作拋出 `std::logic_error`。預取消不會關閉仍可用的 stream,而已發起 的 `write_all()` 被取消後會關閉 stream。`close()` 執行緒安全、冪等;當 close 獲勝時,已接納 -操作恰好一次以 `OperationCancelled` 完成。Phase 6A 在執行緒池上隔離任意同步呼叫;Phase 6B -只提供原生非同步 TCP,不是通用非同步 I/O 層。 +操作恰好一次以 `OperationCancelled` 完成。`TcpListener::bind()` 完成數值位址的 +bind-and-listen,允許連接埠零,並由 `local_port()` 返回實際連接埠。同一 listener 只允許一個 +pending accept;預取消或主動取消 accept 後 listener 仍可使用,close 會取消 pending accept。 +已接受 stream 與 listener 生命週期獨立。Phase 6A 在執行緒池上隔離任意同步呼叫;Phase 6B/6C +提供原生非同步 TCP,不是通用非同步 I/O 層。 RunLoop 不是背景執行緒,也不會把阻塞程式碼自動變成非同步程式碼。如果 Task 暫停後沒有 安排未來的恢復動作,`run()` 可能一直等待。CMP 不提供隱式執行緒親和:外部 awaiter 在其他 @@ -350,7 +365,7 @@ RunLoop 不是背景執行緒,也不會把阻塞程式碼自動變成非同步 ├── src/run_loop.cppm # RunLoop 與 Scheduler 分割區 ├── src/thread_pool.cppm # 固定大小的 CPU worker 排程器 ├── src/blocking.cppm # 結構化阻塞呼叫 offload -├── src/tcp.cppm # 原生非同步 TCP client 與 I/O context +├── src/tcp.cppm # 原生非同步 TCP client/server 與 I/O context ├── src/when_all.cppm # 結構化並行 Task 匯合 ├── src/task_group.cppm # eager 可變結構化 Task 作用域 ├── src/one_shot_event.cppm # 無分配一次性通知 @@ -392,7 +407,7 @@ CI 在 Linux、macOS 和 Windows 上執行等價的建構、測試與獨立範 CMP 目前不追蹤 `mcpp.lock`,`.gitignore` 明確執行這項儲存庫約定。執行期相依放在 `[dependencies]`,gtest 明確宣告在 `[dev-dependencies.compat]` 中。 -目前本機套件包含 10 個測試二進位檔、140 項測試。僅用於 POSIX 的 Release 壓測 consumer 及其 +目前本機套件包含 10 個測試二進位檔、161 項測試。僅用於 POSIX 的 Release 壓測 consumer 及其 成功/失敗資料記錄在 [v1 可開發性壓測](docs/benchmarks/2026-08-29-cmp-v1-readiness.md)。 多 worker 正確性和效能資料記錄在[執行緒池壓測](docs/benchmarks/2026-08-29-cmp-thread-pool.md)。 @@ -406,8 +421,8 @@ CMP 目前不追蹤 `mcpp.lock`,`.gitignore` 明確執行這項儲存庫約定 4. 單調時鐘 Timer v1、可取消就緒/定時等待、變參/vector 匯合、靜止點 TaskGroup、 OneShotEvent、AsyncManualResetEvent 和 AsyncMutex——已實作並完成壓力驗證; 5. 固定大小的多 worker 排程——已實作並完成壓測;work stealing 仍需 profiling 證據; -6. 結構化阻塞 offload 與數值位址原生非同步 TCP client——已實作,並由 PR #10 的 Linux、 - macOS 和 Windows CI 驗證。 +6. 結構化阻塞 offload 與數值位址原生非同步 TCP client/listener——均已實作;client 由 + PR #10、listener 由 PR #11 在 Linux、macOS 和 Windows 上完成 CI 驗證。 剩餘順序只是方向,不代表列出的能力已經實作。 diff --git a/README.zh.md b/README.zh.md index c5bf337..972f8a4 100644 --- a/README.zh.md +++ b/README.zh.md @@ -21,7 +21,8 @@ > CMP 已提供懒启动、单消费者的 `Task` / `Task`、支持变参和 vector 的结构化 > `when_all()`、eager 结构化 `TaskGroup`、一次性与可复用事件、RAII `AsyncMutex`,以及在调用 > 线程运行、支持显式调度和单调时钟定时调度的 `RunLoop`,以及固定大小的 CPU `ThreadPool`。 -> Phase 6B 新增显式 `IoContext` 和 move-only `TcpStream`,用于原生异步的数值地址 TCP client。 +> Phase 6B/6C 新增显式 `IoContext`、move-only `TcpStream` 和 move-only `TcpListener`,用于原生 +> 异步的数值地址 TCP client 与 server。 > `run_blocking()` 可在专用的 pool 实例上执行同步 callable,并通过显式的返回 Scheduler > 交付结果。两种执行器的就绪调度、定时等待、可复用事件等待、TaskGroup 子任务和排队中的 > 阻塞 offload 可显式使用基于 `std::stop_token` 的协作式取消;TCP 操作采用同一 token 模型。 @@ -29,7 +30,7 @@ CMP 计划基于标准无栈 C++ 协程构建现代协程运行时和库。显式 `co_await` 模型现已覆盖固定与 增量结构化并发、一次性事件通知、调用线程与多 worker 调度、单调时钟定时器、可取消等待和 -阻塞工作的结构化隔离,以及一个可移植的原生异步 TCP client 切片。 +阻塞工作的结构化隔离,以及可移植的原生异步 TCP client 与 listener 切片。 ## 为什么叫 CMP? @@ -55,7 +56,7 @@ C++ 标准协程是语言机制,不是完整运行时。因此 CMP 不会宣 - task 会隐式迁移、work stealing 已启用,或所有 I/O 类型都已异步化。 这些能力必须分别设计和验证。CMP 目前通过显式的专用 `ThreadPool` 实例和 -`run_blocking()` 隔离同步工作。Phase 6B 已提供原生异步 TCP client;DNS、监听 socket、TLS、 +`run_blocking()` 隔离同步工作。Phase 6B/6C 已提供原生异步 TCP client 与 listener;DNS、TLS、 文件 I/O 和协作式安全点仍需分别设计。 ## 快速开始 @@ -97,6 +98,7 @@ using mcpplibs::cmp::OneShotEvent; using mcpplibs::cmp::TaskGroup; using mcpplibs::cmp::ThreadPool; using mcpplibs::cmp::IoContext; +using mcpplibs::cmp::TcpListener; using mcpplibs::cmp::TcpStream; using mcpplibs::cmp::run_blocking; using mcpplibs::cmp::when_all; @@ -324,6 +326,16 @@ Task exchange( co_await stream.write_all(caller, request, token); co_return co_await stream.read_some(caller, reply, token); } + +Task accept_one( + IoContext& io, + RunLoop::Scheduler caller, + std::uint16_t port, + std::stop_token token = {}) { + auto listener = co_await TcpListener::bind( + io, caller, "127.0.0.1", port, token); + co_return co_await listener.accept(caller, token); +} ``` 一个不可移动的 `IoContext` 可供多个 stream 共享,并拥有一个私有 I/O driver。`TcpStream` 只能 @@ -331,8 +343,11 @@ Task exchange( 保持到返回的 Task 完成。每种成功或错误都会尝试显式返回 Scheduler。一个 read 和一个 write 可以并存;同方向第二个操作抛出 `std::logic_error`。预取消不会关闭仍可用的 stream,而已发起 的 `write_all()` 被取消后会关闭 stream。`close()` 线程安全、幂等;当 close 获胜时,已接纳 -操作恰好一次以 `OperationCancelled` 完成。Phase 6A 在线程池上隔离任意同步调用;Phase 6B -只提供原生异步 TCP,不是通用异步 I/O 层。 +操作恰好一次以 `OperationCancelled` 完成。`TcpListener::bind()` 完成数值地址的 bind-and-listen, +允许端口零,并由 `local_port()` 返回实际端口。同一 listener 只允许一个 pending accept;预取消 +或主动取消 accept 后 listener 仍可使用,close 会取消 pending accept。已接受 stream 与 listener +生命周期独立。Phase 6A 在线程池上隔离任意同步调用;Phase 6B/6C 提供原生异步 TCP,不是通用 +异步 I/O 层。 RunLoop 不是后台线程,也不会把阻塞代码自动变成异步代码。如果 Task 挂起后没有安排未来的 恢复动作,`run()` 可能一直等待。CMP 不提供隐式线程亲和:外部 awaiter 在其他线程恢复协程 @@ -350,7 +365,7 @@ RunLoop 不是后台线程,也不会把阻塞代码自动变成异步代码。 ├── src/run_loop.cppm # RunLoop 与 Scheduler 分区 ├── src/thread_pool.cppm # 固定大小的 CPU worker 调度器 ├── src/blocking.cppm # 结构化阻塞调用 offload -├── src/tcp.cppm # 原生异步 TCP client 与 I/O context +├── src/tcp.cppm # 原生异步 TCP client/server 与 I/O context ├── src/when_all.cppm # 结构化并发 Task 汇合 ├── src/task_group.cppm # eager 可变结构化 Task 作用域 ├── src/one_shot_event.cppm # 无分配一次性通知 @@ -392,7 +407,7 @@ CI 在 Linux、macOS 和 Windows 上执行等价的构建、测试和独立示 CMP 当前不跟踪 `mcpp.lock`,`.gitignore` 明确执行这一仓库约定。运行时依赖放在 `[dependencies]`,gtest 明确声明在 `[dev-dependencies.compat]` 中。 -当前本地套件包含 10 个测试二进制、140 项测试。仅用于 POSIX 的 Release 压测 consumer 及其 +当前本地套件包含 10 个测试二进制、161 项测试。仅用于 POSIX 的 Release 压测 consumer 及其 成功/失败数据记录在 [v1 可开发性压测](docs/benchmarks/2026-08-29-cmp-v1-readiness.md)。 多 worker 正确性和性能数据记录在[线程池压测](docs/benchmarks/2026-08-29-cmp-thread-pool.md)。 @@ -406,8 +421,8 @@ CMP 当前不跟踪 `mcpp.lock`,`.gitignore` 明确执行这一仓库约定。 4. 单调时钟 Timer v1、可取消就绪/定时等待、变参/vector 汇合、静止点 TaskGroup、 OneShotEvent、AsyncManualResetEvent 和 AsyncMutex——已实现并完成压力验证; 5. 固定大小的多 worker 调度——已实现并完成压测;work stealing 仍需 profiling 证据; -6. 结构化阻塞 offload 与数值地址原生异步 TCP client——已实现,并由 PR #10 的 Linux、 - macOS 和 Windows CI 验证。 +6. 结构化阻塞 offload 与数值地址原生异步 TCP client/listener——均已实现;client 由 + PR #10、listener 由 PR #11 在 Linux、macOS 和 Windows 上完成 CI 验证。 剩余顺序只是方向,不代表列出的能力已经实现。 diff --git a/benchmarks/v1-readiness/src/main.cpp b/benchmarks/v1-readiness/src/main.cpp index 93ebb74..8bffd9f 100644 --- a/benchmarks/v1-readiness/src/main.cpp +++ b/benchmarks/v1-readiness/src/main.cpp @@ -17,6 +17,7 @@ using mcpplibs::cmp::RunLoop; using mcpplibs::cmp::IoContext; using mcpplibs::cmp::Task; using mcpplibs::cmp::TaskGroup; +using mcpplibs::cmp::TcpListener; using mcpplibs::cmp::TcpStream; using mcpplibs::cmp::ThreadPool; using mcpplibs::cmp::run_blocking; @@ -166,58 +167,33 @@ class TemporaryDirectory final { return { std::move(socket), ntohs(address.sin_port) }; } -void send_all(int socket, std::span data) { - std::size_t offset { 0 }; - - while (offset < data.size()) { - const auto sent = ::send( - socket, - data.data() + offset, - data.size() - offset, - 0); - if (sent <= 0) { - throw_socket_error("send"); - } - offset += static_cast(sent); - } -} - -void receive_exactly(int socket, std::span data) { - std::size_t offset { 0 }; - - while (offset < data.size()) { - const auto received = ::recv( - socket, - data.data() + offset, - data.size() - offset, - 0); - if (received <= 0) { - throw_socket_error("recv"); - } - offset += static_cast(received); - } -} - -void run_echo_server( - int listener, +Task run_echo_server( + TcpListener& listener, + Scheduler scheduler, const std::array& payload) { - const int acceptedHandle = ::accept(listener, nullptr, nullptr); - - if (acceptedHandle < 0) { - throw_socket_error("accept"); - } - - Socket accepted { acceptedHandle }; + auto accepted = co_await listener.accept(scheduler); std::array request {}; for (int round { 0 }; round < NETWORK_ROUNDS; ++round) { - receive_exactly(accepted.get(), request); + std::size_t received {}; + + while (received < request.size()) { + const auto size = co_await accepted.read_some( + scheduler, + std::span { request }.subspan(received)); + + if (size == 0) { + throw std::runtime_error { "unexpected client EOF" }; + } + + received += size; + } if (!std::ranges::equal(request, payload)) { throw std::runtime_error { "server payload mismatch" }; } - send_all(accepted.get(), payload); + co_await accepted.write_all(scheduler, payload); } } @@ -328,24 +304,21 @@ Task run_file_io( Task run_network_worker( IoContext& ioContext, - BlockingScheduler blockingWorkers, Scheduler scheduler, std::array payload, Counters& counters) { - auto [listener, port] = bind_loopback_socket(); - - if (::listen(listener.get(), 1) != 0) { - throw_socket_error("listen"); - } + auto listener = co_await TcpListener::bind( + ioContext, + scheduler, + "127.0.0.1", + 0); - // 服务端继续在线程池阻塞;客户端由 IoContext 原生异步驱动。 + // Phase 6C 服务端与客户端共用 IoContext,均不占用 blocking worker。 TaskGroup serverGroup {}; - serverGroup.spawn(run_blocking( - blockingWorkers, + serverGroup.spawn(run_echo_server( + listener, scheduler, - [listenerHandle = listener.get(), payload] { - run_echo_server(listenerHandle, payload); - })); + payload)); int completed {}; @@ -354,7 +327,7 @@ Task run_network_worker( ioContext, scheduler, "127.0.0.1", - port); + listener.local_port()); std::array response {}; for (; completed < NETWORK_ROUNDS; ++completed) { @@ -382,7 +355,7 @@ Task run_network_worker( } catch (...) { counters.unexpectedFailures_.fetch_add( NETWORK_ROUNDS - completed); - static_cast(::shutdown(listener.get(), SHUT_RDWR)); + listener.close(); } bool serverFailed {}; @@ -398,6 +371,8 @@ Task run_network_worker( counters.unexpectedFailures_.fetch_add(1); } + listener.close(); + auto [reservation, failurePort] = bind_loopback_socket(); for (int failure { 0 }; failure < NETWORK_FAILURES; ++failure) { @@ -423,7 +398,6 @@ Task run_network_worker( Task run_network_io( IoContext& ioContext, - BlockingScheduler blockingWorkers, Scheduler scheduler, Counters& counters) { std::array payload {}; @@ -433,7 +407,6 @@ Task run_network_io( for (int remaining { NETWORK_WORKERS }; remaining > 0; --remaining) { group.spawn(run_network_worker( ioContext, - blockingWorkers, scheduler, payload, counters)); @@ -521,7 +494,6 @@ int main() { [&](Scheduler scheduler, Counters& counters) { return run_network_io( ioContext, - blockingWorkers.get_scheduler(), scheduler, counters); })); diff --git a/docs/architecture.md b/docs/architecture.md index fdf8546..2604bde 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,9 +15,10 @@ have `std::stop_token` overloads for cooperative cancellation; timed scheduling absolute `steady_clock` deadlines without a timer thread. `ThreadPool::Scheduler::schedule()` explicitly transfers a continuation to any fixed worker and has the same cancellation-winner rule. `run_blocking()` uses a caller-selected ThreadPool instance for synchronous work and publishes the -outcome only after an explicit return Scheduler is reached. Phase 6B adds an immovable `IoContext` -with one private driver and a move-only `TcpStream` for native async numeric-address TCP clients; -all public outcomes still pass through an explicit caller-selected Scheduler. +outcome only after an explicit return Scheduler is reached. Phase 6B/6C add an immovable +`IoContext` with one private driver, a move-only `TcpStream`, and a move-only `TcpListener` for +native async numeric-address TCP clients and servers; all public outcomes still pass through an +explicit caller-selected Scheduler. The repository contains: @@ -108,7 +109,7 @@ excluded by `.gitignore`. ## Build and tests `.xlings.json` pins the mcpp version used by the project. `mcpp build` builds the inferred library -target. `mcpp test` discovers ten test files and links a gtest entry point for each. The 140 tests +target. `mcpp test` discovers ten test files and links a gtest entry point for each. The 161 tests verify Task ownership and symmetric transfer together with structured joins, root execution, scheduling, exception propagation, timed and cross-thread wake-up, cancellation races, invalid scheduler use, loop reuse, stack-safe repeated completion, and TCP lifecycle/load behavior. @@ -442,10 +443,10 @@ thread on which the coroutine currently executes. - the return schedule is intentionally uncancellable so every outcome reaches one executor; - this is thread-based isolation and does not claim native non-blocking I/O. -`IoContext` and `TcpStream` have the following contract: +`IoContext`, `TcpStream`, and `TcpListener` have the following contract: - one immovable context owns one private I/O driver and may be shared by many streams; -- a move-only stream connects only to numeric IPv4/IPv6 text; DNS and listening APIs are absent; +- a move-only stream connects only to numeric IPv4/IPv6 text; DNS is absent; - `connect()`, `read_some()`, and `write_all()` are lazy Tasks and publish success or failure only after the explicit return Scheduler hop; - read/write spans borrow their storage until the Task completes; a stream permits one pending read @@ -455,7 +456,14 @@ thread on which the coroutine currently executes. closes the stream; - `close()` is thread-safe, idempotent, and non-blocking; close and context shutdown cancel accepted operations exactly once when they win the completion race; -- Phase 6A isolates arbitrary synchronous work on threads, while Phase 6B is native async TCP only. +- a move-only listener lazily binds numeric IPv4/IPv6 text, accepts port zero, and exposes the + selected port through `local_port()`; +- one accept may be pending; pre/active cancellation leaves the listener usable, while listener + close or context shutdown cancels that accept exactly once when it wins; +- a successful accept returns an ordinary `TcpStream`; closing the listener does not close streams + already accepted; +- Phase 6A isolates arbitrary synchronous work on threads, while Phase 6B/6C are native async TCP + only. There is no public free-standing `sync_wait`, detached execution, standalone Timer handle, generic asynchronous-I/O hierarchy, custom frame allocator, or distinct blocking-pool type. @@ -479,7 +487,7 @@ package contract: 1. TaskGroup result handles and additional cancellation-aware primitives; 2. channels and additional structured wake-up paths; 3. profile-guided work stealing if representative workloads justify it; -4. additional native I/O families such as DNS, listeners, TLS, or files; +4. additional native I/O families such as DNS, TLS, or files; 5. result adapters and optional coroutine-frame allocation strategies. Task, cancellation, RunLoop, ThreadPool, blocking offload, TCP, `when_all`, TaskGroup, OneShotEvent, @@ -500,15 +508,16 @@ cd examples/basic mcpp run ``` -The expected result is a successful library build, 140 passing tests across ten binaries, and an +The expected result is a successful library build, 161 passing tests across ten binaries, and an example that prints `Coroutine result: 42`, `Concurrent result: 42`, `Worker pool result: 42`, `Blocking result: 42`, `Task group result: 42`, `Recursive group result: 3`, `Event signalled`, `Reusable event cycles: 2`, `Mutex result: 42`, then `Coroutine cancelled` and exits with status 0. Tests retain the existing high-volume stack checks and add 20,000 recursive TaskGroup admissions, 100,000 pre-cancelled ready schedules, 50,000 manual event waiters, 20,000 nested reusable-event signals, set/cancel races, queued blocking cancellation, -5,000 concurrent blocking offloads, a 32-client TCP load, and repeated TCP close/stop completion -races. Focused race suites pass repeated Release runs. Compute, +5,000 concurrent blocking offloads, 32-client stream and listener loads, repeated stream +close/stop races, and 100 close/accept plus 500 stop/accept race gates. Focused race suites pass +repeated Release runs. Compute, temporary-file, and loopback-network counts and throughput are recorded in the [v1 readiness benchmark](benchmarks/2026-08-29-cmp-v1-readiness.md). ThreadPool counts, concurrency, diff --git a/docs/architecture.zh.hant.md b/docs/architecture.zh.hant.md index 93d0777..313fcdf 100644 --- a/docs/architecture.zh.hant.md +++ b/docs/architecture.zh.hant.md @@ -14,9 +14,9 @@ CMP 是一個具備小型協程執行核心的 C++23 模組專案。根模組匯 `std::stop_token` 的協作式取消多載;定時排程使用相對和絕對的 `steady_clock` 期限,且不建立 計時執行緒。`ThreadPool::Scheduler::schedule()` 會把 continuation 明確轉移到任意固定 worker,並採用相同的取消獲勝規則。`run_blocking()` 使用呼叫端選擇的 ThreadPool 實例執行 -同步工作,並只在到達明確返回 Scheduler 後發布結果。Phase 6B 新增擁有一個私有 driver 的 -不可移動 `IoContext`,以及用於原生非同步數值位址 TCP client 的 move-only `TcpStream`;所有 -公開結果仍透過呼叫端明確選擇的 Scheduler 返回。 +同步工作,並只在到達明確返回 Scheduler 後發布結果。Phase 6B/6C 新增擁有一個私有 driver 的 +不可移動 `IoContext`,以及用於原生非同步數值位址 TCP client/server 的 move-only +`TcpStream` 和 `TcpListener`;所有公開結果仍透過呼叫端明確選擇的 Scheduler 返回。 儲存庫現有內容包括: @@ -103,7 +103,7 @@ Asio 型別。`compat.gtest = "1.15.2"` 是測試使用的明確命名空間開 ## 建置與測試 `.xlings.json` 固定專案使用的 mcpp 版本。`mcpp build` 建置自動推斷的函式庫目標。 -`mcpp test` 會找到十個測試檔案,並為每個檔案連結 gtest 進入點。140 項測試同時驗證 Task 所有權 +`mcpp test` 會找到十個測試檔案,並為每個檔案連結 gtest 進入點。161 項測試同時驗證 Task 所有權 和對稱轉移、結構化匯合,以及根任務執行、普通與定時排程、例外傳播、跨執行緒期限喚醒、 無效 Scheduler、取消競態、RunLoop 重複使用、不會增長呼叫堆疊的重複完成,以及 TCP 生命週期和負載。 @@ -419,10 +419,10 @@ Task;明確等待原 Scheduler 才會把 continuation 送回對應 RunLoop。 - 返回排程刻意不可取消,使每個結果都到達一個執行器; - 這是基於執行緒的隔離,不表示原生非阻塞 I/O。 -`IoContext` 與 `TcpStream` 遵循以下契約: +`IoContext`、`TcpStream` 與 `TcpListener` 遵循以下契約: - 一個不可移動的 context 擁有一個私有 I/O driver,並可由多個 stream 共用; -- move-only stream 只連線數值 IPv4/IPv6 文字;不提供 DNS 或監聽 API; +- move-only stream 只連線數值 IPv4/IPv6 文字;不提供 DNS; - `connect()`、`read_some()` 和 `write_all()` 都是延遲 Task,成功或失敗只在明確返回 Scheduler 跳轉後發布; - read/write span 借用底層儲存直到 Task 完成;一個 stream 允許一個 pending read 和一個 @@ -431,7 +431,12 @@ Task;明確等待原 Scheduler 才會把 continuation 送回對應 RunLoop。 - 預取消不發起 I/O,並保持 open stream 可用;已發起 write 被取消會關閉 stream; - `close()` 執行緒安全、冪等且非阻塞;當 close 或 context shutdown 贏得競態時,已接納操作 恰好取消一次; -- Phase 6A 在執行緒上隔離任意同步工作,Phase 6B 只提供原生非同步 TCP。 +- move-only listener 延遲繫結數值 IPv4/IPv6 文字,允許連接埠零,並由 `local_port()` 返回實際 + 連接埠; +- 同一 listener 只允許一個 pending accept;預取消或主動取消後 listener 仍可使用,listener + close 或 context shutdown 贏得競態時會恰好取消一次; +- accept 成功返回普通 `TcpStream`;關閉 listener 不會關閉已經接受的 stream; +- Phase 6A 在執行緒上隔離任意同步工作,Phase 6B/6C 只提供原生非同步 TCP。 目前沒有公開自由函式 `sync_wait`、detached 執行、獨立 Timer 控制代碼、通用非同步 I/O 層、 自訂協程框架 allocator 或獨立的阻塞執行緒池型別。取消仍是明確的:Scheduler、event 和 TCP @@ -452,7 +457,7 @@ CMP 名稱中的 `C` 與 Go 執行期中的 `G` 相呼應,但這只說明命 1. TaskGroup 結果控制代碼及更多支援取消的原語; 2. channel 和更多結構化喚醒路徑; 3. 代表性負載證明有必要時再加入 profiling 驅動的工作竊取; -4. DNS、監聽、TLS 或檔案等其他原生 I/O 類型; +4. DNS、TLS 或檔案等其他原生 I/O 類型; 5. 結果適配器和可選的協程框架配置策略。 Task、cancellation、RunLoop、ThreadPool、blocking offload、TCP、`when_all`、TaskGroup、 @@ -472,15 +477,16 @@ cd examples/basic mcpp run ``` -預期結果是函式庫建置成功、十個二進位檔中的 140 項測試全部通過,而且範例依序輸出 +預期結果是函式庫建置成功、十個二進位檔中的 161 項測試全部通過,而且範例依序輸出 `Coroutine result: 42`、`Concurrent result: 42`、`Worker pool result: 42`、 `Blocking result: 42`、`Task group result: 42`、`Recursive group result: 3`、`Event signalled`、 `Reusable event cycles: 2`、 `Mutex result: 42` 和 `Coroutine cancelled` 後以狀態 0 結束。測試保留原有高容量堆疊安全 檢查,並增加兩萬次 TaskGroup 遞迴接納、十萬次預取消就緒排程、五萬個 manual event 等待者、 -兩萬次巢狀可複用事件訊號、set/cancel 競態、排隊阻塞取消、5,000 個並行阻塞 offload、 -32 客戶端 TCP 負載,以及重複的 TCP close/stop completion 競態;重點競態套件已連續執行多輪 -Release 測試。計算、臨時檔案和回環網路的成功/失敗計數及吞吐記錄在 +兩萬次巢狀可複用事件訊號、set/cancel 競態、排隊阻塞取消、5,000 個並行阻塞 offload、各 32 +客戶端的 stream/listener 負載、重複的 stream close/stop 競態,以及 100 次 close/accept 與 +500 次 stop/accept 競態門檻;重點競態套件已連續執行多輪 Release 測試。計算、臨時檔案和回環 +網路的成功/失敗計數及吞吐記錄在 [v1 可開發性壓測](benchmarks/2026-08-29-cmp-v1-readiness.md)。ThreadPool 的計數、並行和五輪 Release 資料記錄在[執行緒池壓測](benchmarks/2026-08-29-cmp-thread-pool.md)。目前 Windows LLVM 工具鏈 不會產生 GNU depfile;如果模組介面包含的檔案發生變更,增量建置可能沿用舊的 BMI 或 diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 1378321..b0e2610 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -14,9 +14,9 @@ CMP 是一个具备小型协程执行核心的 C++23 模块项目。根模块导 `std::stop_token` 的协作式取消重载;定时调度使用相对和绝对的 `steady_clock` 期限,且不创建 定时线程。`ThreadPool::Scheduler::schedule()` 会把 continuation 显式转移到任意固定 worker, 并采用相同的取消获胜规则。`run_blocking()` 使用调用方选择的 ThreadPool 实例执行同步工作, -并只在到达显式返回 Scheduler 后发布结果。Phase 6B 新增拥有一个私有 driver 的不可移动 -`IoContext`,以及用于原生异步数值地址 TCP client 的 move-only `TcpStream`;所有公共结果仍 -通过调用方显式选择的 Scheduler 返回。 +并只在到达显式返回 Scheduler 后发布结果。Phase 6B/6C 新增拥有一个私有 driver 的不可移动 +`IoContext`,以及用于原生异步数值地址 TCP client/server 的 move-only `TcpStream` 和 +`TcpListener`;所有公共结果仍通过调用方显式选择的 Scheduler 返回。 仓库现有内容包括: @@ -103,7 +103,7 @@ Asio 类型。`compat.gtest = "1.15.2"` 是测试使用的显式命名空间开 ## 构建与测试 `.xlings.json` 固定项目使用的 mcpp 版本。`mcpp build` 构建自动推断的库目标。 -`mcpp test` 发现十个测试文件,并为每个文件链接 gtest 入口。140 项测试同时验证 Task 所有权和 +`mcpp test` 发现十个测试文件,并为每个文件链接 gtest 入口。161 项测试同时验证 Task 所有权和 对称转移、结构化汇合,以及根任务执行、普通与定时调度、异常传播、跨线程期限唤醒、无效 Scheduler、取消竞态、RunLoop 复用、不会增长调用栈的重复完成,以及 TCP 生命周期和负载。 @@ -418,10 +418,10 @@ RunLoop 不拥有工作线程,也不提供自动线程亲和。外部 awaiter - 返回调度刻意不可取消,使每个结果都到达一个执行器; - 这是基于线程的隔离,不表示原生非阻塞 I/O。 -`IoContext` 与 `TcpStream` 遵循以下契约: +`IoContext`、`TcpStream` 与 `TcpListener` 遵循以下契约: - 一个不可移动的 context 拥有一个私有 I/O driver,并可由多个 stream 共享; -- move-only stream 只连接数值 IPv4/IPv6 文本;不提供 DNS 或监听 API; +- move-only stream 只连接数值 IPv4/IPv6 文本;不提供 DNS; - `connect()`、`read_some()` 和 `write_all()` 都是懒 Task,成功或失败只在显式返回 Scheduler 跳转后发布; - read/write span 借用底层存储直到 Task 完成;一个 stream 允许一个 pending read 和一个 @@ -430,7 +430,11 @@ RunLoop 不拥有工作线程,也不提供自动线程亲和。外部 awaiter - 预取消不发起 I/O,并保持 open stream 可用;已发起 write 被取消会关闭 stream; - `close()` 线程安全、幂等且非阻塞;当 close 或 context shutdown 赢得竞态时,已接纳操作 恰好取消一次; -- Phase 6A 在线程上隔离任意同步工作,Phase 6B 只提供原生异步 TCP。 +- move-only listener 懒绑定数值 IPv4/IPv6 文本,允许端口零,并由 `local_port()` 返回实际端口; +- 同一 listener 只允许一个 pending accept;预取消或主动取消后 listener 仍可使用,listener + close 或 context shutdown 赢得竞态时会恰好取消一次; +- accept 成功返回普通 `TcpStream`;关闭 listener 不会关闭已经接受的 stream; +- Phase 6A 在线程上隔离任意同步工作,Phase 6B/6C 只提供原生异步 TCP。 目前没有公共自由函数 `sync_wait`、detached 执行、独立 Timer 句柄、通用异步 I/O 层、自定义 协程帧 allocator 或独立的阻塞线程池类型。取消仍是显式的:Scheduler、event 和 TCP 等待 @@ -450,7 +454,7 @@ CMP 名称中的 `C` 与 Go 运行时中的 `G` 相呼应,但这只说明命 1. TaskGroup 结果句柄及更多支持取消的原语; 2. channel 和更多结构化唤醒路径; 3. 代表性负载证明有必要时再加入 profiling 驱动的工作窃取; -4. DNS、监听、TLS 或文件等其他原生 I/O 类型; +4. DNS、TLS 或文件等其他原生 I/O 类型; 5. 结果适配器和可选的协程帧分配策略。 Task、cancellation、RunLoop、ThreadPool、blocking offload、TCP、`when_all`、TaskGroup、 @@ -470,14 +474,15 @@ cd examples/basic mcpp run ``` -预期结果是库构建成功、十个二进制中的 140 项测试全部通过,并且示例依次输出 +预期结果是库构建成功、十个二进制中的 161 项测试全部通过,并且示例依次输出 `Coroutine result: 42`、`Concurrent result: 42`、`Worker pool result: 42`、 `Blocking result: 42`、`Task group result: 42`、`Recursive group result: 3`、`Event signalled`、 `Reusable event cycles: 2`、 `Mutex result: 42` 和 `Coroutine cancelled` 后以状态 0 退出。测试保留原有高容量栈安全检查, 并增加两万次 TaskGroup 递归接纳、十万次预取消就绪调度、五万个 manual event 等待者、两万次 -嵌套可复用事件信号、set/cancel 竞态、排队阻塞取消、5,000 个并发阻塞 offload、32 客户端 -TCP 负载,以及重复的 TCP close/stop completion 竞态;重点竞态套件已连续执行多轮 Release 测试。 +嵌套可复用事件信号、set/cancel 竞态、排队阻塞取消、5,000 个并发阻塞 offload、各 32 客户端 +的 stream/listener 负载、重复的 stream close/stop 竞态,以及 100 次 close/accept 与 500 次 +stop/accept 竞态门禁;重点竞态套件已连续执行多轮 Release 测试。 计算、临时文件和回环网络的成功/失败计数及吞吐记录在 [v1 可开发性压测](benchmarks/2026-08-29-cmp-v1-readiness.md)。ThreadPool 的计数、并发和五轮 Release 数据记录在[线程池压测](benchmarks/2026-08-29-cmp-thread-pool.md)。当前 Windows LLVM 工具链不会 diff --git a/docs/benchmarks/2026-08-29-cmp-v1-readiness.md b/docs/benchmarks/2026-08-29-cmp-v1-readiness.md index abb8d66..d1bb894 100644 --- a/docs/benchmarks/2026-08-29-cmp-v1-readiness.md +++ b/docs/benchmarks/2026-08-29-cmp-v1-readiness.md @@ -1,19 +1,20 @@ # CMP v1 可开发性与压力基线 -**日期:** 2026-08-29 +**日期:** 2026-08-30 -**结论:** Phase 6B TCP client 最终本地门禁的 5 轮 Release 压测全部通过;三类场景均无 -非预期失败。 +**结论:** Phase 6C TCP client/server 最终本地门禁的 5 轮 Release 压测全部通过;三类场景 +均无非预期失败。 ## 测试边界 本报告验证 CMP v1 的结构化生命周期、调度、跨线程唤醒和失败传播能够承载计算任务;文件 -和 TCP loopback server 继续通过专用 CMP `ThreadPool` 与 `run_blocking()` 隔离,TCP client -则使用 Phase 6B `IoContext`/`TcpStream` 原生异步路径。 +继续通过专用 CMP `ThreadPool` 与 `run_blocking()` 隔离,TCP loopback client/server 则使用 +Phase 6B/6C `IoContext`、`TcpStream` 与 `TcpListener` 原生异步路径。 -压测程序位于 `benchmarks/v1-readiness`,只直接使用标准库、CMP 和服务端所需的 POSIX 回环 -socket;Asio 由 CMP 路径依赖传递,不在 benchmark 重复声明。该 benchmark 不进入跨平台 CI。 -任何计数不匹配或非预期失败都会让进程以非零状态退出。 +压测程序位于 `benchmarks/v1-readiness`,只直接使用标准库、CMP,以及构造未监听失败端口所需 +的 POSIX socket;正常 TCP 服务端不再使用阻塞 socket。Asio 由 CMP 路径依赖传递,不在 +benchmark 重复声明。该 benchmark 不进入跨平台 CI。任何计数不匹配或非预期失败都会让进程 +以非零状态退出。 ## 环境与复现 @@ -21,7 +22,7 @@ socket;Asio 由 CMP 路径依赖传递,不在 benchmark 重复声明。该 b - 处理器:AMD Ryzen 7 7735H,8 核 16 线程; - 内存:约 11.7 GiB; - 临时目录文件系统:ext4; -- mcpp:2026.8.11.2; +- mcpp:2026.8.28.1; - 编译器:LLVM 22.1.8; - 阻塞 worker:4; - 原生异步 I/O driver:1; @@ -30,7 +31,7 @@ socket;Asio 由 CMP 路径依赖传递,不在 benchmark 重复声明。该 b ```bash cd benchmarks/v1-readiness mcpp build --profile release --strict --cache=off -./target/x86_64-linux-gnu/937ff8fc1b673bcb/bin/cmp-v1-readiness +./target/x86_64-linux-gnu/4b99bcba98e861c5/bin/cmp-v1-readiness ``` 目标目录中的平台哈希可能随环境变化;如果路径不同,使用构建输出给出的可执行文件路径。 @@ -41,9 +42,9 @@ mcpp build --profile release --strict --cache=off 任务注入一个预期异常并在结构化子任务内捕获。 - `file_io`:4 个结构化 offload,各在专用 ThreadPool worker 上执行 250 次 64 KiB 临时文件 写入、读取和内容校验,再执行 25 次缺失文件读取以验证预期失败路径。 -- `network_loopback`:4 个同步 echo server 各占用一个专用 ThreadPool worker;4 个 - `TcpStream` 共用一个 `IoContext`,各执行 5,000 次 256 字节回环请求/响应,再对被占用但未 - 监听的端口执行 25 次连接,并且只把 `connection_refused` 计为预期失败。 +- `network_loopback`:4 个 `TcpListener`/服务端 `TcpStream` 与 4 个客户端 `TcpStream` 共用 + 一个 `IoContext`,各执行 5,000 次 256 字节回环请求/响应,不占用 blocking worker;随后对 + 被占用但未监听的端口执行 25 次连接,并且只把 `connection_refused` 计为预期失败。 吞吐量按“成功操作 + 预期失败操作”的总操作数计算。 @@ -51,31 +52,34 @@ mcpp build --profile release --strict --cache=off | 轮次 | 场景 | 操作数 | 成功 | 预期失败 | 非预期失败 | 耗时 ms | ops/s | 状态 | | ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | -| 1 | compute | 50,000 | 49,000 | 1,000 | 0 | 30.367 | 1,646,518.5 | PASS | -| 1 | file_io | 1,100 | 1,000 | 100 | 0 | 230.594 | 4,770.3 | PASS | -| 1 | network_loopback | 20,100 | 20,000 | 100 | 0 | 2,246.887 | 8,945.7 | PASS | -| 2 | compute | 50,000 | 49,000 | 1,000 | 0 | 31.310 | 1,596,926.1 | PASS | -| 2 | file_io | 1,100 | 1,000 | 100 | 0 | 179.101 | 6,141.8 | PASS | -| 2 | network_loopback | 20,100 | 20,000 | 100 | 0 | 2,165.041 | 9,283.9 | PASS | -| 3 | compute | 50,000 | 49,000 | 1,000 | 0 | 38.062 | 1,313,656.2 | PASS | -| 3 | file_io | 1,100 | 1,000 | 100 | 0 | 212.912 | 5,166.4 | PASS | -| 3 | network_loopback | 20,100 | 20,000 | 100 | 0 | 1,985.032 | 10,125.8 | PASS | -| 4 | compute | 50,000 | 49,000 | 1,000 | 0 | 29.358 | 1,703,112.6 | PASS | -| 4 | file_io | 1,100 | 1,000 | 100 | 0 | 181.380 | 6,064.6 | PASS | -| 4 | network_loopback | 20,100 | 20,000 | 100 | 0 | 2,078.127 | 9,672.2 | PASS | -| 5 | compute | 50,000 | 49,000 | 1,000 | 0 | 27.611 | 1,810,856.0 | PASS | -| 5 | file_io | 1,100 | 1,000 | 100 | 0 | 593.669 | 1,852.9 | PASS | -| 5 | network_loopback | 20,100 | 20,000 | 100 | 0 | 2,004.864 | 10,025.6 | PASS | +| 1 | compute | 50,000 | 49,000 | 1,000 | 0 | 25.998 | 1,923,205.6 | PASS | +| 1 | file_io | 1,100 | 1,000 | 100 | 0 | 183.207 | 6,004.1 | PASS | +| 1 | network_loopback | 20,100 | 20,000 | 100 | 0 | 2,239.147 | 8,976.6 | PASS | +| 2 | compute | 50,000 | 49,000 | 1,000 | 0 | 25.443 | 1,965,140.8 | PASS | +| 2 | file_io | 1,100 | 1,000 | 100 | 0 | 147.661 | 7,449.5 | PASS | +| 2 | network_loopback | 20,100 | 20,000 | 100 | 0 | 2,286.284 | 8,791.6 | PASS | +| 3 | compute | 50,000 | 49,000 | 1,000 | 0 | 25.246 | 1,980,522.4 | PASS | +| 3 | file_io | 1,100 | 1,000 | 100 | 0 | 149.355 | 7,365.0 | PASS | +| 3 | network_loopback | 20,100 | 20,000 | 100 | 0 | 2,297.859 | 8,747.3 | PASS | +| 4 | compute | 50,000 | 49,000 | 1,000 | 0 | 26.662 | 1,875,306.9 | PASS | +| 4 | file_io | 1,100 | 1,000 | 100 | 0 | 138.760 | 7,927.4 | PASS | +| 4 | network_loopback | 20,100 | 20,000 | 100 | 0 | 2,236.655 | 8,986.6 | PASS | +| 5 | compute | 50,000 | 49,000 | 1,000 | 0 | 24.359 | 2,052,612.2 | PASS | +| 5 | file_io | 1,100 | 1,000 | 100 | 0 | 131.145 | 8,387.6 | PASS | +| 5 | network_loopback | 20,100 | 20,000 | 100 | 0 | 2,169.108 | 9,266.5 | PASS | ## 汇总 | 场景 | 最短 / 中位 / 最长耗时 ms | 最高 / 中位 / 最低 ops/s | 五轮非预期失败 | | --- | ---: | ---: | ---: | -| compute | 27.611 / 30.367 / 38.062 | 1,810,856.0 / 1,646,518.5 / 1,313,656.2 | 0 | -| file_io | 179.101 / 212.912 / 593.669 | 6,141.8 / 5,166.4 / 1,852.9 | 0 | -| network_loopback | 1,985.032 / 2,078.127 / 2,246.887 | 10,125.8 / 9,672.2 / 8,945.7 | 0 | - -相同负载的 Phase 6A 阻塞 client 历史中位数为 2,627.611 ms / 7,649.5 ops/s;Phase 6B 原生 -异步 client 的本轮中位数为 2,078.127 ms / 9,672.2 ops/s。该差异只作为本机回归证据,不是 -跨机器 SLA 或 CI 阈值。结果说明 v1 核心可以同时承载结构化协程、blocking offload 和 -单 driver 原生异步 TCP;work stealing 仍需由独立的代表性负载证明必要性。 +| compute | 24.359 / 25.443 / 26.662 | 2,052,612.2 / 1,965,140.8 / 1,875,306.9 | 0 | +| file_io | 131.145 / 147.661 / 183.207 | 8,387.6 / 7,449.5 / 6,004.1 | 0 | +| network_loopback | 2,169.108 / 2,239.147 / 2,297.859 | 9,266.5 / 8,976.6 / 8,747.3 | 0 | + +相同负载的 Phase 6A 阻塞 client 历史中位数为 2,627.611 ms / 7,649.5 ops/s;Phase 6B +“原生异步 client + 4 个阻塞 server”中位数为 2,078.127 ms / 9,672.2 ops/s;Phase 6C +client/server 都由单 driver 原生异步驱动后的中位数为 2,239.147 ms / 8,976.6 ops/s。Phase 6C +减少了 4 个阻塞 worker,但每轮请求增加了服务端 return-Scheduler 跳转,因此本机吞吐低于 +Phase 6B;这些数据只作为回归证据,不是跨机器 SLA 或 CI 阈值。结果说明 v1 核心可同时承载 +结构化协程、blocking offload 和单 driver 原生异步 TCP client/server;只有代表性负载确认 +该单 driver 是瓶颈后,才设计多 driver 或其他调度优化。 diff --git a/docs/superpowers/plans/2026-08-30-cmp-phase6c-tcp-server-v1.md b/docs/superpowers/plans/2026-08-30-cmp-phase6c-tcp-server-v1.md new file mode 100644 index 0000000..df94c7a --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-cmp-phase6c-tcp-server-v1.md @@ -0,0 +1,192 @@ +# CMP Phase 6C TCP Server v1 Implementation Plan + +**Date:** 2026-08-30 +**Design:** `docs/superpowers/specs/2026-08-30-cmp-phase6c-tcp-server-v1-design.md` +**Status:** Complete; local and Linux/macOS/Windows CI verified in PR #11 +**Baseline:** `feature/phase6c-tcp-server-v1` from `main` commit `9492075`; 140/140 tests + +## Execution Rule + +Implement only the approved listener slice. Reuse the existing `:tcp` partition, `IoContext`, +`TcpSocketState`, native-operation bridge, `TcpStream`, Scheduler constraints, and cancellation +exception. Run the smallest focused check after each implementation gate and stop on the first +failure. + +Do not add DNS, TLS, generic endpoints, socket options, a resource hierarchy, multiple driver +threads, detached handlers, or another dependency. + +## 1. Extend context ownership for listeners + +In `src/tcp.cppm`: + +- forward-declare one private `TcpListenerState`; +- add one linear weak-listener registry beside the existing weak-socket registry; +- register listeners only on the I/O driver; +- close listeners before streams during context shutdown; +- keep the existing admission mutex, ordered shutdown post, work-guard drain, and driver join. + +Do not replace the two concrete registries with a virtual resource base. Add one short +`ponytail:` comment explaining that the linear registry changes only after measured cost. + +Run a strict cache-off Dev build before adding public APIs. + +## 2. Add driver setup bridge and listener state + +Add one private void driver-operation awaiter used only for bind setup. Its shared state owns the +private continuation and any exception. `await_suspend()` posts one callable through +`IoContextState::post()`; the callable runs on the sole driver, stores its outcome, and resumes the +private helper. `await_resume()` rethrows a stored exception. + +Add `TcpListenerState` with: + +- weak context ownership; +- one optional Asio acceptor; +- atomic logical-open and close-requested flags; +- atomic single-accept admission; +- one weak active accept operation; +- the bound local port; +- driver-only bind, operation attach, cancel-and-close, and native-close helpers. + +All driver callables and native close paths are non-throwing at their outer boundary. Original +system errors are captured for the helper Task rather than escaping the driver loop. + +Run a strict cache-off Dev build. + +## 3. Implement lazy `TcpListener::bind()` + +Add the exact public traits and signature from the Design. The public function is a non-coroutine +wrapper that copies the weak context state, Scheduler, address string, port, and stop token into a +private lazy helper. + +On the driver: + +1. reject a pre-requested stop before parsing; +2. parse numeric IPv4/IPv6 only; +3. open, bind, and listen using the native maximum backlog; +4. query/store the actual local port; +5. mark open and register the listener. + +Every outcome then awaits the uncancellable return Scheduler. A failed return hop closes an +undelivered bound listener through RAII. + +Add focused tests for traits, laziness/address ownership, IPv4/IPv6, port zero, invalid address, +occupied port, pre-cancellation, ThreadPool return affinity, and invalid return Schedulers. + +Run focused Dev and Release `tcp_test` checks. + +## 4. Implement cancellable `accept()` + +Reuse `NativeOperationAwaiter` and pre-create one candidate `TcpSocketState` on the driver. Register +the candidate with the context before initiation, attach the active operation to the listener, +then call `async_accept()` with the candidate socket and the existing cancellation-bound completion +handler. + +On success, mark the candidate socket open only if context shutdown has not already closed it, then +construct `TcpStream`. On error or cancellation, close the candidate before publishing. Hold +listener accept admission until after the return hop. Do not close the listener for an unrelated +native accept error. + +Add focused tests for one accepted bidirectional exchange, pre/active cancellation with reuse, +single-pending rejection, return affinity, and accepted-stream independence from listener close. + +Run focused Dev and Release checks. + +## 5. Complete close, move, shutdown, and race boundaries + +Implement public `close()`, `is_open()`, and `local_port()`. Destruction uses the same close request; +move construction transfers ownership and the port snapshot. + +Explicit close and context shutdown tag the active accept operation before closing the acceptor. +Only the Asio handler resumes an initiated accept. Check close-requested immediately before native +initiation to cover validation-to-initiation races. + +Add deterministic tests for close during accept, repeated close, moved-from behavior, context +shutdown with a pending accept, listener survival after context destruction, and repeated +stop/close versus completion races with exactly one outcome. + +Run the focused Release race tests repeatedly without exhausting this host's small ephemeral-port +range. + +## 6. Add bounded listener load and migrate readiness server + +In `tests/tcp_test.cpp`, run a bounded multi-client loop through one listener. Accept sequentially +and use structured child Tasks for established streams. Assert one completion and correct payload +for every client, with zero cancellation, errors, wrong-thread publication, or duplicates. + +In `benchmarks/v1-readiness/src/main.cpp`, replace only the blocking POSIX accept/read/write server +path with `TcpListener` plus accepted `TcpStream` operations. Preserve compute/file workloads, +network workers/rounds/failure probes, payload size, CSV columns, and exact accounting. Retain POSIX +only where the benchmark already needs it for the held non-listening failure endpoint. + +Build the benchmark in Release strict cache-off mode and run five rounds. Timing is evidence, not +a threshold; all hard counts must pass with zero unexpected failures. + +## 7. Synchronize documentation + +After behavior is verified, update all README and architecture language variants with: + +- `TcpListener::bind()` and `accept()`; +- numeric-address and port-zero behavior; +- one-pending-accept rule; +- explicit return affinity; +- accept cancellation, close, and context-shutdown behavior; +- accepted-stream independence; +- updated module/test counts and readiness server description. + +Keep `examples/basic` unchanged and run it as the external path-dependency compatibility check. + +## 8. Full verification and state record + +Run: + +```text +mcpp build --profile dev --strict --cache=off +mcpp test --profile dev --strict --cache=off +mcpp build --profile release --strict --cache=off +mcpp test --profile release --strict --cache=off +cd examples/basic && mcpp run +cd benchmarks/v1-readiness && mcpp build --profile release --strict --cache=off +``` + +Then run five readiness rounds and the focused Release accept races repeatedly. Record only checks +actually executed. + +Finally: + +- self-review the complete diff for lifetime, cancellation, duplicate-resume, return-affinity, + and scope errors; +- mark the Design implemented locally and this Plan complete only after all local gates pass; +- update `.agent/HANDOFF.md` with current branch, exact test totals, benchmark counts, known limits, + and remaining remote CI. + +Do not commit, push, create a PR, or merge without separate user authorization. + +## Completion Record + +All eight implementation gates are complete locally. `TcpListener` reuses the existing TCP +partition and native operation path; the readiness server now uses `TcpListener` and accepted +`TcpStream` operations, while the POSIX helper remains only for the non-listening failure probe. +Self-review also corrected the bind/connect/accept shutdown publication window by rechecking each +unpublished resource after its return-Scheduler hop. Reusable read/write/accept operation slots +now rely on their pending-admission flags instead of the previous Task frame's destruction timing. + +Strict cache-off Dev and Release builds passed, and both complete suites passed 161/161 tests in +ten binaries. The TCP binary contains 45 tests, including ThreadPool and invalid-Scheduler return +paths for both bind and accept, a deterministic shutdown-before-publication gate, a 32-client +listener load, context shutdown, and repeated completion races. The publication gate passed 20 +repeated Release runs; the stream/listener concurrent-close gate passed 100 Release runs with 800 +concurrent close calls; +five race repetitions passed 100 close/accept and 500 stop/accept races. The unchanged standalone +example passed. Five final readiness rounds reported exact +success/expected failure counts and zero unexpected failures; native async TCP client/server +median throughput was 8,976.6 ops/s. + +Remote completion: implementation commit `e0248ca` and the reviewed release-plan companion were +pushed on `feature/phase6c-tcp-server-v1`. PR #11 then passed Linux x86_64 in 2m43s, macOS arm64 in +2m45s, and Windows x86_64 in 2m49s. Merge remains a separately authorized operation. + +## Scope Guard + +If implementation requires public endpoints, backlog/options, multiple simultaneous accepts, +multiple drivers, DNS, TLS, detached ownership, or a generic I/O resource base, stop and write a +separate design. None belongs in Phase 6C v1. diff --git a/docs/superpowers/plans/2026-08-30-cmp-phase7-v1-release-qualification.md b/docs/superpowers/plans/2026-08-30-cmp-phase7-v1-release-qualification.md new file mode 100644 index 0000000..1821e34 --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-cmp-phase7-v1-release-qualification.md @@ -0,0 +1,175 @@ +# CMP Phase 7 v1 Release Qualification Implementation Plan + +**Date:** 2026-08-30 +**Design:** `docs/superpowers/specs/2026-08-30-cmp-phase7-v1-release-qualification-design.md` +**Status:** Pending Phase 6C merge and authorized branch transition +**Target package:** `mcpplibs.cmp` `0.1.0` + +## Execution Rule + +Qualify the existing API; do not add another runtime feature. Stop at the first failing gate, +record the exact evidence, and fix only the root cause. Do not weaken tests, float tool versions, +or add packaging infrastructure already supplied by mcpp. + +Do not commit, push, create or merge a PR, create a tag or Release, or modify mcpp-index without +the corresponding explicit user authorization. + +## 1. Satisfy the entry gate + +- submit Phase 6C only after authorization; +- require its Linux x86_64, macOS arm64, and Windows x86_64 checks to pass; +- merge only after separate authorization; +- synchronize local `main` and create a Phase 7 branch only after authorization; +- confirm that no Phase 6C worktree changes were lost or mixed with unrelated work. + +No Phase 7 implementation file changes occur before this gate. + +## 2. Freeze and audit the release surface + +Read the root module, public partitions, README variants, architecture variants, manifest, +license, example, and three CI workflows. Confirm that every exported v1 type is documented and +that every documented type is exported. + +Search public source and consumer-facing docs for: + +- leaked Asio or platform socket types; +- stale test/module counts; +- claims for DNS, TLS, UDP, detached ownership, work stealing, or other excluded behavior; +- stale toolchain, xlings, mcpp, Asio, gtest, version, repository, or license metadata. + +Correct only factual mismatches. + +## 3. Strengthen the existing CI matrix + +Edit only `.github/workflows/ci-linux.yml`, `ci-macos.yml`, and `ci-windows.yml` unless a proven +platform defect requires a targeted source/test fix. + +Replace the default-profile build/test steps with strict cache-off Dev and Release commands. Keep +the existing pinned bootstrap and platform runner choices. Run the example once and add its strict +cache-off Release build. + +Do not add a new workflow, matrix abstraction, benchmark job, cache service, sanitizer job, or +floating dependency. + +Validate YAML structure locally with the smallest available parser; if no parser is installed, +review indentation and let GitHub Actions be the authoritative workflow parser. + +## 4. Run local release qualification + +From the repository root: + +```text +mcpp build --profile dev --strict --cache=off +mcpp test --profile dev --strict --cache=off +mcpp build --profile release --strict --cache=off +mcpp test --profile release --strict --cache=off +``` + +From `examples/basic`: + +```text +mcpp run +mcpp build --profile release --strict --cache=off +``` + +From `benchmarks/v1-readiness`: + +```text +mcpp build --profile release --strict --cache=off + # five sequential runs +``` + +Select the executable produced by the current build rather than another retained target hash. +All five rounds must keep the documented hard counts and report zero unexpected failures. + +Run analyzer checks only for files changed by any release fix. Keep focused race repetitions +bounded to avoid host ephemeral-port exhaustion. + +## 5. Validate the package transformation + +Run an early preflight with: + +```text +mcpp publish --dry-run --allow-dirty +``` + +Treat that archive as evidence only for the committed baseline: with the pinned tool, +`--allow-dirty` permits the command but does not include uncommitted content. After the release +candidate is merged and clean, repeat without `--allow-dirty`. Verify the printed namespace, +name, version, repository URL, license, archive name, hash, and release URL. + +Copy the generated descriptor to a temporary directory. For the pinned mcpp `2026.8.28.1`, +normalize that temporary/index candidate to the native-package Form A before validating it: + +```text +namespace = "mcpplibs" +name = "cmp" +omit the inline mcpp segment +mcpp xpkg parse /cmp.lua +``` + +Keep ordinary string URLs unless a real CN mirror exists and is authorized. Do not copy the +manifest's source list into the descriptor; the extracted source's `mcpp.toml` is the single +build description. + +Extract the generated source archive into another temporary directory. There, run strict Dev and +Release root build/tests, the example run, and its Release build. Delete only those explicit +temporary directories after validation. + +Do not check generated archives or descriptors into this repository. + +## 6. Self-review the complete Phase 7 change + +Review every changed line for: + +- accidental API or version changes; +- inconsistent commands across the three workflows; +- Windows shell/path assumptions; +- claims ahead of evidence; +- generated or local files entering release inputs; +- weakened test, strictness, or cache behavior; +- remote steps that blur authorization boundaries. + +Run whitespace/encoding checks without changing unrelated CRLF files. Update `.agent/HANDOFF.md` +with exact local results and remaining remote gates. + +## 7. Run the authorized project PR gates + +Only after separate authorization: + +- create the local commit with a concise Chinese message; +- push the Phase 7 branch; +- create the PR; +- wait for Linux, macOS, and Windows checks; +- diagnose and fix any failure through the same Design/Plan loop; +- merge only after separate authorization. + +Record actual workflow URLs and outcomes. “PR opened” is not equivalent to “CI passed”. + +## 8. Run the separately authorized publication gates + +From merged, synchronized `main`, and only with authorization for each remote mutation: + +1. rerun the clean-tree package preflight; +2. create and push annotated tag `v0.1.0`; +3. create the GitHub Release and attach the exact generated archive; +4. prepare the mcpp-index Form A descriptor, minimal consumer test member, catalogue entries, and + index-required design record; +5. create the mcpp-index package PR and wait for descriptor validation and three-platform consumer + builds; +6. merge the index PR only with authorization; +7. verify `mcpp search cmp` and an isolated `mcpp add cmp@0.1.0` consumer after index publication. + +Only then update public documentation to say the package is installable from mcpp-index. + +## 9. Close the v1 roadmap + +Update the Design, this Plan, public documentation, and HANDOFF with evidence rather than intent. +Mark Phase 7 complete only after every acceptance criterion in the Design is proven. + +Do not start a Phase 8 automatically. A future feature begins with a concrete consumer problem and +a new independently reviewed Design. + +## Completion Record + +Pending. Fill this section only with commands and remote checks actually completed. diff --git a/docs/superpowers/specs/2026-08-30-cmp-phase6c-tcp-server-v1-design.md b/docs/superpowers/specs/2026-08-30-cmp-phase6c-tcp-server-v1-design.md new file mode 100644 index 0000000..2a74311 --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-cmp-phase6c-tcp-server-v1-design.md @@ -0,0 +1,350 @@ +# CMP Phase 6C TCP Server v1 Design + +**Date:** 2026-08-30 +**Status:** Implemented; local and Linux/macOS/Windows CI verified in PR #11 +**Baseline:** `main` at PR #10 squash commit `9492075`; 140/140 Dev and Release tests + +## Purpose + +Add the smallest portable TCP server surface that reuses Phase 6B's `IoContext`, `TcpStream`, +native Asio backend, cancellation model, and explicit return-Scheduler contract. + +The normal developer path is intentionally short: + +```cpp +IoContext io {}; +RunLoop loop {}; + +auto serve_one(RunLoop::Scheduler caller) -> Task { + auto listener = co_await TcpListener::bind( + io, + caller, + "127.0.0.1", + 8080); + auto stream = co_await listener.accept(caller); + + std::array request {}; + const auto size = co_await stream.read_some(caller, request); + co_await stream.write_all(caller, std::span { request }.first(size)); +} +``` + +`TcpListener` owns only the listening socket. Each successful `accept()` returns the existing +move-only `TcpStream`; accepted connections keep working after the listener is closed. + +## Scope + +Phase 6C v1 includes only: + +- numeric IPv4 and IPv6 bind addresses; +- one move-only `TcpListener` on an explicit `IoContext`; +- bind-and-listen as one lazy operation; +- one cancellable asynchronous accept at a time; +- thread-safe, idempotent, non-blocking close; +- open-state inspection and the bound local port; +- explicit completion through a caller-selected Scheduler; +- loopback correctness, race, load, and readiness-benchmark coverage. + +It excludes DNS, interface-name resolution, TLS, UDP, Unix-domain sockets, peer/local endpoint +objects, configurable backlog, arbitrary socket options, port sharing, half-close, timeouts, +connection limits, accept queues above the operating-system backlog, and detached client handlers. + +The existing Asio dependency already supplies the Linux epoll, macOS kqueue, and Windows IOCP +accept backend. No dependency or toolchain change is needed. + +## Public API + +Add `TcpListener` to the existing `mcpplibs.cmp:tcp` partition and root module: + +```cpp +namespace mcpplibs::cmp { + +class TcpListener final { +public: + TcpListener() = delete; + TcpListener(const TcpListener&) = delete; + TcpListener& operator=(const TcpListener&) = delete; + TcpListener(TcpListener&&) noexcept; + TcpListener& operator=(TcpListener&&) = delete; + + ~TcpListener(); + + template + requires ( + std::move_constructible && + requires(const ReturnScheduler& scheduler) { + scheduler.schedule(); + } + ) + [[nodiscard]] static Task bind( + IoContext& context, + ReturnScheduler returnTo, + std::string_view numericAddress, + std::uint16_t port, + std::stop_token stopToken = {}); + + template + requires ( + std::move_constructible && + requires(const ReturnScheduler& scheduler) { + scheduler.schedule(); + } + ) + [[nodiscard]] Task accept( + ReturnScheduler returnTo, + std::stop_token stopToken = {}); + + void close() noexcept; + [[nodiscard]] bool is_open() const noexcept; + [[nodiscard]] std::uint16_t local_port() const noexcept; +}; + +} // namespace mcpplibs::cmp +``` + +`bind()` performs address parse, native bind, and native listen as one operation. A returned +listener is ready to accept. Port zero is passed to the operating system; `local_port()` exposes +the selected nonzero ephemeral port. It returns the last successfully bound port after close and +zero only for a moved-from listener. + +The operating system's maximum listen backlog is used. v1 does not add a backlog parameter or +silently set `reuse_address`; both change deployment policy and need a concrete consumer contract. + +## Ownership and Laziness + +`TcpListener` is the single public owner of a private shared listener state. Shared ownership is +only an implementation device that keeps an accepted native operation alive. + +- `bind()` is lazy; calling it performs no address parse, bind, listen, or driver post. +- The public wrapper copies `numericAddress`, the weak context handle, Scheduler, and stop token + before returning, so a temporary string is safe. +- `accept()` is lazy and captures the listener state by value rather than retaining `this`. +- Moving a listener transfers close ownership. A moved-from listener is closed, has port zero, + accepts no operations, and tolerates repeated `close()`. +- Destroying a listener performs the same close request as `close()`. +- An accepted `TcpStream` has independent ownership. Closing or destroying the listener does not + close streams already selected by successful accept completion. + +## Driver and Bind Contract + +All native acceptor access is serialized on the `IoContext` driver. Bind uses one private +driver-operation bridge: it posts setup to the driver, records either success or an exception, and +resumes only the private helper coroutine. The helper then awaits `returnTo.schedule()` before +publishing the result. The provided RunLoop and ThreadPool Schedulers move public continuations +off the Asio driver; a custom Scheduler owns its own thread-transfer semantics. + +On the driver, bind: + +1. accepts the context post; +2. lets a pre-requested stop win before parsing or opening; +3. parses only a numeric address; +4. opens an acceptor for that address family; +5. binds the requested port; +6. listens with the native maximum backlog; +7. records the actual local port and registers the listener for context shutdown. + +Invalid text, address-in-use, permission, unsupported-family, bind, and listen failures are +reported as `std::system_error` using Asio's original `std::error_code`. Port zero is valid. +Hostnames such as `localhost` are invalid because no resolver is invoked. + +Bind cancellation is cooperative only until driver setup starts. A stop request observed before +setup throws `OperationCancelled` and allocates no native listener. Once setup has begun it is a +short synchronous driver action and completion wins; CMP does not add a second cancellation race +inside open/bind/listen. + +## Accept Contract + +`accept()` creates one candidate `TcpSocketState` on the I/O driver, registers it with the context +before native initiation, and passes its socket to `asio::ip::tcp::acceptor::async_accept()`. Early +registration makes context shutdown own the candidate even when the kernel has selected a +connection but its handler has not yet run. This keeps the existing `TcpStream` implementation and +all of its read, write, EOF, cancellation, and close behavior unchanged. + +On success, the candidate socket is marked open only if context shutdown has not already closed +it, wrapped in a `TcpStream`, and published after the explicit return-Scheduler hop. If shutdown +already closed it, accept reports `OperationCancelled`. On failure the candidate is closed and no +stream escapes. A native accept error not caused by local cancellation does not itself close the +listener; the application may decide whether to retry. + +A listener permits exactly one accepted-but-not-yet-published `accept()` operation. A second +pending accept throws `std::logic_error` after its own return-Scheduler hop. Admission remains held +through result publication, making overlap independent of driver timing. Applications that need +continuous service write an ordinary loop and start a structured handler for each returned stream. + +## Cancellation and Close + +`accept(stopToken)` reuses the existing per-operation Asio cancellation signal. Asio 1.38.1 +documents terminal, partial, and total per-operation cancellation for `async_accept` on POSIX and +Windows. + +- Resource validation and single-accept admission happen before pre-cancellation. +- A pre-stopped token starts no native accept and leaves the listener usable. +- An active stop request cancels only that accept and leaves the listener usable. +- If accept completion already won, the connected stream remains the result. +- Explicit listener close or context shutdown winning first completes the accepted operation once + with `OperationCancelled`. +- Only the Asio accept handler resumes an initiated accept; stop and close merely request that + handler, preventing duplicate resume. + +`close()` atomically marks the listener closed, rejects later work, and posts native close to its +`IoContext`. It is thread-safe, idempotent, non-blocking, non-throwing, and a no-op on a moved-from +handle. A close request racing driver initiation is checked before `async_accept()` so the already +admitted operation still completes as `OperationCancelled`. + +Closing a listener never closes an accepted `TcpStream`. Closing the shared `IoContext` still +closes both listeners and streams because the context owns the native driver lifetime. + +## Context Shutdown + +`IoContextState` keeps a second linear weak registry for listeners. This avoids introducing a +polymorphic resource hierarchy for two concrete native resource types. + +Shutdown remains ordered through the existing admission mutex and driver queue: + +1. reject new context posts; +2. close registered listeners and tag pending accepts as context cancellation; +3. close registered streams and tag their pending operations; +4. release the work guard; +5. drain native handlers and enqueue every return-Scheduler hop; +6. join the driver. + +An operation racing shutdown is either accepted then completed/cancelled exactly once, or rejected +with `std::logic_error`. A listener may outlive its context, but it is closed and later accepts fail +safely. Its last local port remains inspectable. + +## Errors and Completion Affinity + +- `OperationCancelled`: accepted bind/accept work lost to its token, listener close, or context + shutdown as described above; +- `std::system_error`: address parsing or native open/bind/listen/accept failure; +- `std::logic_error`: moved-from/closed listener use, expired/stopping context, overlapping accept, + or an invalid return Scheduler. + +Every bind or accept success and error attempts `returnTo.schedule()` before publication. If that +hop fails, the Scheduler exception is observable. A successfully accepted but unpublished stream +is closed by RAII; a successfully bound but unpublished listener is likewise closed. + +CMP does not retry bind or accept errors. Application policy owns retry delay, admission limits, +protocol handling, and shutdown structure. + +## Minimal Implementation Boundary + +Keep implementation changes to: + +- `src/tcp.cppm`: listener state, bind bridge, public `TcpListener`, and context registry extension; +- `tests/tcp_test.cpp`: portable loopback listener contracts and race/load checks; +- `benchmarks/v1-readiness/src/main.cpp`: replace only its blocking TCP server with `TcpListener`; +- the existing README and architecture variants after verification; +- this Design, its Plan, and `.agent/HANDOFF.md`. + +No new module partition, dependency, executor abstraction, generic endpoint, resource base class, +callback API, or public native handle is needed. + +`examples/basic` stays unchanged. A live socket example would make the basic coroutine sample +longer while tests and the readiness consumer already compile and run the complete public server +path. + +## Validation Contract + +Leave deterministic tests for: + +- exact listener copy/move/noexcept traits; +- bind laziness, expired-context rejection, owned temporary address storage, and an accept Task + retaining state across a later listener-handle move; +- numeric IPv4 bind, ephemeral port discovery, accept, bidirectional exchange, and RunLoop + affinity; +- IPv6 loopback when available; +- invalid numeric address and occupied port as `std::system_error` on the return Scheduler; +- pre-cancelled bind skipping invalid-address parsing; +- bind and accept return through a `ThreadPool::Scheduler`, with invalid return Scheduler cleanup; +- pre-cancelled and actively cancelled accept leaving the listener usable; +- deterministic rejection of a second accept both before native completion and while a successful + stream still waits for return-Scheduler publication; +- close during accept, concurrent idempotent close, move ownership, and moved-from rejection; +- accepted streams surviving listener close; +- stop/close versus accept completion races with exactly one result; +- context destruction draining a pending accept and closing a surviving listener; +- multiple concurrent clients accepted and echoed without lost or duplicate completion; +- the readiness benchmark preserving hard success/failure accounting with a native async server. + +Tests use only loopback addresses, port zero, gates/barriers, and bounded deadlock guards. They do +not access the public internet, depend on fixed sleeps, or assert platform-specific error numbers. + +## Acceptance Criteria + +Phase 6C v1 is complete when: + +1. a consumer imports only `mcpplibs.cmp` and uses the shown bind/accept path; +2. no public Asio type leaks through CMP; +3. bind and accept outcomes return through the selected Scheduler; +4. listener close, operation cancellation, and context shutdown complete accepted work exactly + once; +5. accepted stream ownership is independent from listener ownership; +6. strict cache-off Dev and Release builds and all tests pass; +7. `examples/basic` still passes unchanged; +8. the readiness benchmark reports zero unexpected failures in five Release rounds; +9. English, Simplified Chinese, and Traditional Chinese docs state the same boundary. + +PR #11 passed the final Linux x86_64, macOS arm64, and Windows x86_64 cross-platform gate. + +## Self-review Corrections + +The implementation review tightened four boundaries before coding: + +1. Register each candidate socket before `async_accept()`, not after success. Otherwise context + shutdown could finish its registry scan while a selected connection still waited for handler + publication. +2. Accept success must use a non-terminating “mark open if still present” path. Context shutdown is + allowed to close the registered candidate before a queued success handler runs; that outcome is + cancellation, not an invariant failure. +3. A stop request cancels only the pending accept. It never closes the listener or a connection + whose successful handler already won. +4. Unrelated native accept errors leave listener policy to the caller instead of silently closing + a socket that may still accept later connections. + +## Local Completion Evidence + +- `TcpListener` bind, accept, cancellation, close, move, context shutdown, and accepted-stream + ownership are implemented in the existing TCP partition without a new dependency or public Asio + type. +- Bind, connect, and accept recheck an unpublished resource after the return-Scheduler hop; + context shutdown winning that publication window reports `OperationCancelled`. +- Read, write, and accept operation slots rely only on their pending-admission flags and may + replace a completed weak operation without waiting for the previous Task frame to be destroyed. +- Stream and listener concurrent-close gates passed 100 Release runs with 800 concurrent close + calls; each pending read or accept completed exactly once. +- Strict cache-off Dev and Release builds passed; both complete suites passed 161/161 tests across + ten binaries, including 45 TCP tests. The deterministic publication-window gate also passed 20 + repeated Release runs. +- Five repeated Release race runs covered 100 close/accept and 500 stop/accept races with one + outcome each. The standalone example passed unchanged. +- Five final Release readiness rounds kept all compute, file, and native async TCP client/server + hard counts exact with zero unexpected failures; network median was 2,239.147 ms / 8,976.6 ops/s. + +## Remote Completion Evidence + +PR #11 ran the existing pinned-toolchain build, ten test binaries, and standalone example on Linux +x86_64, macOS arm64, and Windows x86_64. All three checks passed on the implementation head. + +## Deliberately Excluded + +- DNS, service names, interface enumeration, and endpoint result objects; +- TLS, HTTP, framing, serialization, proxying, and authentication; +- UDP, Unix-domain sockets, pipes, terminals, and files; +- configurable backlog, reuse/exclusive-address flags, native handles, and arbitrary options; +- peer address inspection and accepted-socket option inheritance APIs; +- timeouts, rate limits, connection quotas, backpressure queues, and overload policy; +- multiple simultaneous accepts on one listener or multiple I/O driver threads; +- callbacks, futures, detached handlers, automatic TaskGroup ownership, and implicit executors; +- automatic retry, logging, metrics, graceful protocol drain, and process signal integration. + +Each excluded capability needs a concrete consumer and a separate contract. None is required to +prove portable listener ownership and accept completion. + +## References + +- Phase 6B design: `docs/superpowers/specs/2026-08-29-cmp-phase6b-tcp-client-v1-design.md` +- Asio 1.38.1 packaged `basic_socket_acceptor.hpp`, including documented POSIX/Windows + per-operation cancellation for `async_accept` +- [Standalone Asio `basic_socket_acceptor`](https://think-async.com/Asio/asio-1.38.2/doc/asio/reference/basic_socket_acceptor.html) +- [Standalone Asio per-operation cancellation](https://think-async.com/Asio/asio-1.38.2/doc/asio/overview/core/cancellation.html) diff --git a/docs/superpowers/specs/2026-08-30-cmp-phase7-v1-release-qualification-design.md b/docs/superpowers/specs/2026-08-30-cmp-phase7-v1-release-qualification-design.md new file mode 100644 index 0000000..140efdf --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-cmp-phase7-v1-release-qualification-design.md @@ -0,0 +1,218 @@ +# CMP Phase 7 v1 Release Qualification Design + +**Date:** 2026-08-30 +**Status:** Reviewed design; implementation waits for Phase 6C merge and a new branch +**Package version:** `0.1.0` +**Baseline:** Phase 6C local tree, 161/161 Dev and Release tests + +## Purpose + +Turn the completed v1 capability set into a reproducible first release candidate. Phase 7 proves +that the public package can be built, tested, consumed, packaged, and validated on its declared +platforms without adding another runtime feature. + +Here, “v1” names the first usable CMP capability set. The package remains `0.1.0`: the project has +not yet made the compatibility promise implied by semantic version `1.0.0`. + +## Scope + +Phase 7 includes only: + +- freezing the public API implemented through Phase 6C; +- strict cache-off Dev and Release CI on Linux x86_64, macOS arm64, and Windows x86_64; +- standalone path-consumer verification in Dev and Release configurations; +- local readiness and release-package validation; +- checking package metadata and the generated mcpp-index descriptor; +- recording exact local, CI, package, and index evidence. + +It does not add Task, Scheduler, synchronization, network, DNS, TLS, UDP, file-I/O, logging, +metrics, work-stealing, allocator, or compatibility APIs. A release failure is fixed at its root; +it is not hidden by weakening tests or expanding the runtime surface. + +## Entry Gate + +Phase 7 implementation starts only after: + +1. Phase 6C is committed and pushed with explicit user authorization; +2. its PR passes the existing Linux, macOS, and Windows workflows; +3. the PR is merged with separate authorization; +4. local `main` is synchronized and a dedicated Phase 7 branch is created with authorization. + +The Design and Plan may exist before that gate. Runtime, CI, version, and release-file changes may +not be mixed into the Phase 6C implementation branch. + +## Release Candidate Contract + +The release candidate exports the existing root module `mcpplibs.cmp` and keeps all current +contracts unchanged: + +- lazy single-consumer `Task` and explicit root driving; +- RunLoop timers and cooperative cancellation; +- `when_all`, TaskGroup, events, and AsyncMutex; +- fixed-size ThreadPool and structured `run_blocking()`; +- one-driver `IoContext`, numeric-address `TcpStream`, and `TcpListener`. + +The existing exclusions remain exclusions. Phase 7 does not reinterpret them as release defects. +In particular, the first package does not promise DNS, TLS, UDP, generic I/O, detached tasks, +automatic cancellation propagation, or a multi-driver scheduler. + +## Version and Metadata + +Keep the manifest version at `0.1.0`. Before packaging, verify that `mcpp.toml` contains the exact +package identity and current repository metadata: + +```toml +[package] +namespace = "mcpplibs" +name = "cmp" +version = "0.1.0" +standard = "c++23" +license = "Apache-2.0" +repo = "https://github.com/mcpplibs/cmp" +``` + +The release source must include the license, public module sources, required package metadata, +tests, and the standalone example. mcpp may preserve other tracked repository documentation in +the source archive; only `mcpp.toml` controls the package's build inputs. Generated build +directories, local tool environments, editor state, and generated release artifacts must not +become package inputs. + +## CI Qualification + +Keep the three existing platform workflows and their current pinned xlings/mcpp environment. Each +workflow must execute the same release-relevant commands: + +```text +mcpp build --profile dev --strict --cache=off +mcpp test --profile dev --strict --cache=off +mcpp build --profile release --strict --cache=off +mcpp test --profile release --strict --cache=off +cd examples/basic +mcpp run +mcpp build --profile release --strict --cache=off +``` + +The example run remains the external import and runtime check. Its Release build adds a consumer +configuration check without depending on an unstable generated executable path. + +Readiness throughput is not a CI threshold. The benchmark is Linux-local evidence because timing +and the current failure-endpoint helper are host-specific. CI remains deterministic and checks +correctness, exact test counts, and process exit status. + +## Local Qualification + +Before requesting the Phase 7 PR, run: + +- strict cache-off Dev and Release root builds and all tests; +- the standalone example run and strict cache-off Release build; +- the readiness consumer in Release for five rounds, requiring exact success/failure counts and + zero unexpected failures; +- focused Release race gates only when the implementation changed or a platform failure points at + a race. Do not exhaust this host's 301-port ephemeral range with unbounded TCP repetitions; +- analyzer checks for every source or test file changed during release fixes. + +Performance remains diagnostic data. A throughput change is reported, not failed, unless a future +separate Spec defines a reproducible threshold and environment. + +## Package and Index Preflight + +Use mcpp's existing release path instead of introducing packaging scripts: + +```text +mcpp publish --dry-run +``` + +`mcpp publish --dry-run` may use `--allow-dirty` only for an early local preflight. With the pinned +tool, that flag permits the command but does not put uncommitted worktree content into the source +archive. Such an archive proves only the committed baseline's packaging path. The final +qualification runs from the merged clean release candidate without that flag. The generated +archive is extracted into a temporary directory and its root package, tests, and bundled example +are built from that extracted source to catch missing package inputs. + +The pinned mcpp `2026.8.28.1` emits a useful starting descriptor, but its inline `mcpp` segment +does not contain `sources`, so that generated file does not parse directly. Normalize only a +temporary/index candidate to the mcpp-index native-package Form A: + +- keep `namespace = "mcpplibs"` and use the atomic `name = "cmp"`; +- remove the inline `mcpp` segment so the source archive's own `mcpp.toml` remains authoritative; +- keep ordinary string URLs unless a real, separately authorized CN mirror exists; +- run `mcpp xpkg parse ` on that candidate. + +Do not duplicate the source list in the index descriptor to accommodate this generator mismatch. +The package manifest already owns that information. + +Do not add a second packager, release script, checked-in generated descriptor, or checked-in +archive. mcpp already owns those transformations. + +## Remote Release Boundary + +These are distinct, separately authorized remote operations: + +1. push the Phase 7 branch; +2. create its PR; +3. merge the PR after all three CI workflows pass; +4. create and push the `v0.1.0` tag; +5. create the GitHub Release and attach the generated archive; +6. open the mcpp-index package PR with its Form A descriptor, minimal consumer test member, + catalogue updates, and index-required design record; +7. merge the index PR after its validation and three-platform consumer matrix pass. + +Authorization for one item does not authorize the next. A successful project PR is not evidence +that the GitHub Release or package-index entry exists. + +## Documentation + +English, Simplified Chinese, and Traditional Chinese README and architecture variants must agree +on: + +- the implemented v1 capability set and deliberate exclusions; +- package version `0.1.0`; +- the exact local verification commands; +- whether Phase 6C and Phase 7 CI have actually passed; +- whether the package is actually available from mcpp-index. + +Do not publish an index installation command before the index entry exists. The existing Spec and +Plan are sufficient release notes for the first candidate; no speculative changelog framework is +needed. + +## Acceptance Criteria + +Phase 7 is complete only when all of the following have authoritative evidence: + +1. Phase 6C and Phase 7 PRs are merged without unresolved checks; +2. strict cache-off Dev and Release suites pass all 161 tests on Linux, macOS, and Windows; +3. the standalone example runs and its Release consumer build passes on all three platforms; +4. five final local readiness rounds have zero unexpected failures and exact hard counts; +5. the clean-tree publish dry-run succeeds; +6. the normalized Form A descriptor parses, and the extracted release source rebuilds, tests, and + runs its example; +7. the `v0.1.0` GitHub Release contains the generated archive; +8. the mcpp-index PR passes validation and its three-platform consumer matrix; +9. public documentation describes only evidence that actually exists. + +Until the remote items pass, CMP may be called locally release-ready but not publicly released or +index-verified. + +## Self-review Corrections + +The reviews corrected seven likely scope errors: + +1. Keep `0.1.0`; a first usable capability set is not evidence for a `1.0.0` compatibility promise. +2. Reuse `mcpp publish --dry-run`; a custom release script or redundant checked-in descriptor adds + another failure path. +3. Keep readiness timing out of CI; only deterministic correctness gates belong in the platform + matrix. +4. Separate project CI, GitHub Release, and package-index publication. Each proves a different + boundary and requires separate authorization. +5. End the planned roadmap here. Post-release features require a concrete consumer and their own + Design; Phase 7 does not create a speculative Phase 8 backlog. +6. Treat mcpp's generated descriptor as a starting point, not index-ready truth: the pinned tool + emits an invalid inline source segment and the index requires the atomic name plus Form A. +7. Do not mistake `--allow-dirty` for packaging dirty content. Its archive covers the committed + baseline, so final Phase 6C package evidence must wait for a clean merged candidate. + +## Final Roadmap Boundary + +Phase 7 is the final planned CMP v1 stage. After it, maintenance fixes and demand-driven proposals +may be designed independently. Optional TaskGroup result handles, channels, DNS/TLS adapters, +other native I/O, allocator policies, or profiling-gated scheduler changes are not scheduled work. diff --git a/src/tcp.cppm b/src/tcp.cppm index aed71f1..c1fb2d6 100644 --- a/src/tcp.cppm +++ b/src/tcp.cppm @@ -8,12 +8,14 @@ import :task; namespace mcpplibs::cmp::detail { class TcpSocketState; +class TcpListenerState; struct NativeOperationState; enum class NativeCancellationOrigin { none, stop_token, stream_close, + listener_close, context_shutdown }; @@ -26,12 +28,14 @@ private: WorkGuard workGuard_ { asio::make_work_guard(ioContext_) }; std::mutex admissionMutex_ {}; bool accepting_ { true }; - // ponytail: v1 线性清理弱引用;实测注册或关闭成本显著后再换索引结构。 + // ponytail: v1 线性清理两类弱引用;实测注册或关闭成本显著后再换索引结构。 std::vector> sockets_ {}; + std::vector> listeners_ {}; void shutdown_on_driver_() noexcept; friend class TcpSocketState; + friend class TcpListenerState; public: void ensure_accepting() { @@ -61,6 +65,14 @@ public: sockets_.push_back(socket); } + void register_listener_on_driver( + const std::shared_ptr& listener) { + std::erase_if(listeners_, [](const auto& registered) { + return registered.expired(); + }); + listeners_.push_back(listener); + } + void run_driver() noexcept { try { ioContext_.run(); @@ -106,12 +118,16 @@ public: return *socket_; } - void mark_open_on_driver() noexcept { - if (!socket_ || logicallyOpen_.exchange( - true, - std::memory_order_release)) { + [[nodiscard]] bool try_mark_open_on_driver() noexcept { + if (!socket_) { + return false; + } + + if (logicallyOpen_.exchange(true, std::memory_order_release)) { std::terminate(); } + + return true; } [[nodiscard]] std::shared_ptr lock_context() const { @@ -257,7 +273,171 @@ public: } }; +class TcpListenerState final { +private: + std::weak_ptr context_ {}; + std::optional acceptor_ {}; + std::atomic logicallyOpen_ { false }; + std::atomic closeRequested_ { false }; + std::atomic acceptPending_ { false }; + std::atomic localPort_ {}; + std::weak_ptr acceptOperation_ {}; + +public: + explicit TcpListenerState( + const std::shared_ptr& context) + : context_ { context } {} + + void bind_on_driver( + std::string_view numericAddress, + std::uint16_t port) { + std::error_code error {}; + const auto address = asio::ip::make_address( + numericAddress, + error); + + if (error) { + throw std::system_error { error }; + } + + const auto context = lock_context(); + acceptor_.emplace(context->ioContext_); + const asio::ip::tcp::endpoint endpoint { address, port }; + acceptor_->open(endpoint.protocol(), error); + + if (!error) { + acceptor_->bind(endpoint, error); + } + + if (!error) { + acceptor_->listen( + asio::socket_base::max_listen_connections, + error); + } + + std::uint16_t localPort {}; + + if (!error) { + localPort = acceptor_->local_endpoint(error).port(); + } + + if (error) { + close_on_driver(); + throw std::system_error { error }; + } + + localPort_.store(localPort, std::memory_order_release); + + if (logicallyOpen_.exchange(true, std::memory_order_release)) { + std::terminate(); + } + } + + [[nodiscard]] asio::ip::tcp::acceptor& acceptor_on_driver() { + if (!acceptor_) { + throw std::logic_error { "TCP listener is closed" }; + } + + return *acceptor_; + } + + [[nodiscard]] std::shared_ptr lock_context() const { + const auto context = context_.lock(); + + if (!context) { + throw std::logic_error { "I/O context no longer exists" }; + } + + return context; + } + + void require_open() const { + if (!logicallyOpen_.load(std::memory_order_acquire)) { + throw std::logic_error { "TCP listener is closed" }; + } + } + + [[nodiscard]] bool is_open() const noexcept { + return logicallyOpen_.load(std::memory_order_acquire); + } + + [[nodiscard]] bool close_requested() const noexcept { + return closeRequested_.load(std::memory_order_acquire); + } + + [[nodiscard]] std::uint16_t local_port() const noexcept { + return localPort_.load(std::memory_order_acquire); + } + + [[nodiscard]] bool try_begin_accept() noexcept { + bool expected { false }; + return acceptPending_.compare_exchange_strong( + expected, + true, + std::memory_order_acq_rel); + } + + void finish_accept() noexcept { + if (!acceptPending_.exchange(false, std::memory_order_release)) { + std::terminate(); + } + } + + void attach_accept_operation_on_driver( + const std::shared_ptr& operation) noexcept; + + void cancel_and_close_on_driver( + NativeCancellationOrigin origin) noexcept; + + void close_on_driver() noexcept { + logicallyOpen_.store(false, std::memory_order_release); + + if (!acceptor_) { + return; + } + + std::error_code ignored {}; + acceptor_->close(ignored); + acceptor_.reset(); + } + + void request_close( + const std::shared_ptr& self) noexcept { + closeRequested_.store(true, std::memory_order_release); + + if (!logicallyOpen_.exchange(false, std::memory_order_acq_rel)) { + return; + } + + const auto context = context_.lock(); + + if (!context) { + return; + } + + try { + context->post([self] noexcept { + self->cancel_and_close_on_driver( + NativeCancellationOrigin::listener_close); + }); + } catch (const std::logic_error&) { + // Shutdown 已接管 registry 中的 listener。 + } catch (...) { + std::terminate(); + } + } +}; + void IoContextState::shutdown_on_driver_() noexcept { + for (auto& registered : listeners_) { + if (const auto listener = registered.lock()) { + listener->cancel_and_close_on_driver( + NativeCancellationOrigin::context_shutdown); + } + } + + listeners_.clear(); + for (auto& registered : sockets_) { if (const auto socket = registered.lock()) { socket->cancel_and_close_on_driver( @@ -306,7 +486,7 @@ struct NativeOperationState final { }; std::exception_ptr exception_ {}; bool completed_ { false }; - // 保证 native handler 返回前,connect/read/write 使用的资源仍然有效。 + // 保证 native handler 返回前,connect/accept/read/write 使用的资源仍然有效。 std::shared_ptr lifetime_ {}; // 最先释放,避免停止回调继续访问即将销毁的 operation state。 std::optional stopCallback_ {}; @@ -387,19 +567,21 @@ void TcpSocketState::attach_connect_operation_on_driver( void TcpSocketState::attach_read_operation_on_driver( const std::shared_ptr& operation) noexcept { - if (!operation || !readOperation_.expired()) { + if (!operation) { std::terminate(); } + // readPending_ 已排除重叠;旧 Task 帧可能在完成后短暂存活。 readOperation_ = operation; } void TcpSocketState::attach_write_operation_on_driver( const std::shared_ptr& operation) noexcept { - if (!operation || !writeOperation_.expired()) { + if (!operation) { std::terminate(); } + // writePending_ 已排除重叠;旧 Task 帧可能在完成后短暂存活。 writeOperation_ = operation; } @@ -418,6 +600,25 @@ void TcpSocketState::cancel_and_close_on_driver( close_on_driver(); } +void TcpListenerState::attach_accept_operation_on_driver( + const std::shared_ptr& operation) noexcept { + if (!operation) { + std::terminate(); + } + + // acceptPending_ 已排除重叠;旧 Task 帧可能在完成后短暂存活。 + acceptOperation_ = operation; +} + +void TcpListenerState::cancel_and_close_on_driver( + NativeCancellationOrigin origin) noexcept { + if (const auto operation = acceptOperation_.lock()) { + operation->request_cancellation_on_driver(origin); + } + + close_on_driver(); +} + void NativeOperationState::StopRequest::operator()() const noexcept { const auto operation = operation_.lock(); const auto context = context_.lock(); @@ -519,10 +720,89 @@ public: } }; +struct DriverOperationState final { + std::coroutine_handle<> continuation_ {}; + std::exception_ptr exception_ {}; + bool completed_ { false }; + + void complete_on_driver(std::exception_ptr exception = {}) noexcept { + if (std::exchange(completed_, true)) { + std::terminate(); + } + + exception_ = std::move(exception); + const auto continuation = std::exchange(continuation_, {}); + + if (!continuation || continuation.done()) { + std::terminate(); + } + + continuation.resume(); + } + + void take_result() { + if (!completed_) { + std::terminate(); + } + + if (exception_) { + std::rethrow_exception(exception_); + } + } +}; + +template +class DriverOperationAwaiter final { +private: + std::shared_ptr context_ {}; + Operation operation_; + std::shared_ptr state_ { + std::make_shared() + }; + +public: + DriverOperationAwaiter( + std::shared_ptr context, + Operation operation) + : context_ { std::move(context) }, + operation_ { std::move(operation) } {} + + [[nodiscard]] constexpr bool await_ready() const noexcept { + return false; + } + + void await_suspend(std::coroutine_handle<> continuation) { + auto state = state_; + auto operation = std::move(operation_); + state->continuation_ = continuation; + + context_->post([ + state = std::move(state), + operation = std::move(operation) + ]() mutable noexcept { + try { + std::invoke(std::move(operation)); + state->complete_on_driver(); + } catch (...) { + state->complete_on_driver(std::current_exception()); + } + }); + } + + void await_resume() { + state_->take_result(); + } +}; + struct ConnectOperationState final { std::shared_ptr socket_ {}; }; +struct AcceptOperationState final { + std::shared_ptr listener_ {}; + std::shared_ptr socket_ {}; +}; + static_assert(std::invocable< NativeOperationState::CompletionHandler, std::error_code>); @@ -540,6 +820,7 @@ static_assert(std::is_nothrow_constructible_v< export namespace mcpplibs::cmp { class TcpStream; +class TcpListener; class IoContext final { private: @@ -552,6 +833,7 @@ private: } }; friend class TcpStream; + friend class TcpListener; public: IoContext() = default; @@ -579,6 +861,8 @@ private: std::shared_ptr state) noexcept : state_ { std::move(state) } {} + friend class TcpListener; + template [[nodiscard]] static Task connect_impl_( std::weak_ptr context, @@ -664,6 +948,82 @@ public: std::stop_token stopToken = {}); }; +class TcpListener final { +private: + std::shared_ptr state_ {}; + + explicit TcpListener( + std::shared_ptr state) noexcept + : state_ { std::move(state) } {} + + template + [[nodiscard]] static Task bind_impl_( + std::weak_ptr context, + ReturnScheduler returnTo, + std::string numericAddress, + std::uint16_t port, + std::stop_token stopToken); + + template + [[nodiscard]] static Task accept_impl_( + std::shared_ptr state, + ReturnScheduler returnTo, + std::stop_token stopToken); + +public: + TcpListener() = delete; + TcpListener(const TcpListener&) = delete; + TcpListener& operator=(const TcpListener&) = delete; + + TcpListener(TcpListener&& other) noexcept + : state_ { std::exchange(other.state_, {}) } {} + + TcpListener& operator=(TcpListener&&) = delete; + + ~TcpListener() { + close(); + } + + void close() noexcept { + if (state_) { + state_->request_close(state_); + } + } + + [[nodiscard]] bool is_open() const noexcept { + return state_ && state_->is_open(); + } + + [[nodiscard]] std::uint16_t local_port() const noexcept { + return state_ ? state_->local_port() : 0; + } + + template + requires ( + std::move_constructible && + requires(const ReturnScheduler& scheduler) { + scheduler.schedule(); + } + ) + [[nodiscard]] static Task bind( + IoContext& context, + ReturnScheduler returnTo, + std::string_view numericAddress, + std::uint16_t port, + std::stop_token stopToken = {}); + + template + requires ( + std::move_constructible && + requires(const ReturnScheduler& scheduler) { + scheduler.schedule(); + } + ) + [[nodiscard]] Task accept( + ReturnScheduler returnTo, + std::stop_token stopToken = {}); +}; + template Task TcpStream::connect_impl_( std::weak_ptr weakContext, @@ -730,7 +1090,10 @@ Task TcpStream::connect_impl_( std::terminate(); } - connectState->socket_->mark_open_on_driver(); + if (!connectState->socket_->try_mark_open_on_driver()) { + throw OperationCancelled {}; + } + stream.emplace(TcpStream { connectState->socket_ }); } catch (...) { if (connectState->socket_) { @@ -743,6 +1106,11 @@ Task TcpStream::connect_impl_( // 返回路径不接受取消,保证成功与错误都回到调用者选择的 Scheduler。 co_await returnTo.schedule(); + // Context 可在返回跳转期间关闭尚未发布的资源。 + if (!exception && !stream->is_open()) { + exception = std::make_exception_ptr(OperationCancelled {}); + } + if (exception) { std::rethrow_exception(exception); } @@ -963,6 +1331,184 @@ Task TcpStream::write_all_impl_( } } +template +Task TcpListener::bind_impl_( + std::weak_ptr weakContext, + ReturnScheduler returnTo, + std::string numericAddress, + std::uint16_t port, + std::stop_token stopToken) { + std::shared_ptr listenerState {}; + std::optional listener {}; + std::exception_ptr exception {}; + + try { + const auto context = weakContext.lock(); + + if (!context) { + throw std::logic_error { "I/O context no longer exists" }; + } + + listenerState = std::make_shared( + context); + co_await detail::DriverOperationAwaiter { + context, + [ + context, + listenerState, + numericAddress = std::move(numericAddress), + port, + stopToken + ]() mutable { + if (stopToken.stop_requested()) { + throw OperationCancelled {}; + } + + try { + listenerState->bind_on_driver( + numericAddress, + port); + context->register_listener_on_driver(listenerState); + } catch (...) { + listenerState->close_on_driver(); + throw; + } + } + }; + listener.emplace(TcpListener { listenerState }); + } catch (...) { + exception = std::current_exception(); + } + + // 返回路径不接受取消,保证成功与错误都回到调用者选择的 Scheduler。 + co_await returnTo.schedule(); + + // Context 可在返回跳转期间关闭尚未发布的资源。 + if (!exception && !listener->is_open()) { + exception = std::make_exception_ptr(OperationCancelled {}); + } + + if (exception) { + std::rethrow_exception(exception); + } + + co_return std::move(*listener); +} + +template +Task TcpListener::accept_impl_( + std::shared_ptr state, + ReturnScheduler returnTo, + std::stop_token stopToken) { + std::shared_ptr acceptState {}; + std::optional stream {}; + std::exception_ptr exception {}; + bool admitted { false }; + const auto releaseAdmission = [&] noexcept { + if (std::exchange(admitted, false)) { + state->finish_accept(); + } + }; + + try { + if (!state) { + throw std::logic_error { "TCP listener was moved from" }; + } + + const auto context = state->lock_context(); + context->ensure_accepting(); + state->require_open(); + + if (!state->try_begin_accept()) { + throw std::logic_error { + "TCP listener already has a pending accept" + }; + } + + admitted = true; + + if (stopToken.stop_requested()) { + throw OperationCancelled {}; + } + + acceptState = std::make_shared(); + acceptState->listener_ = state; + const auto native = co_await detail::NativeOperationAwaiter { + context, + stopToken, + acceptState, + [context, acceptState, stopToken]( + const auto& operation, + auto completion) mutable { + if (stopToken.stop_requested()) { + throw OperationCancelled {}; + } + + auto socket = std::make_shared( + context); + acceptState->socket_ = socket; + // 先登记候选 socket,确保 context shutdown 不会漏掉已选连接。 + context->register_socket_on_driver(socket); + acceptState->listener_->attach_accept_operation_on_driver( + operation); + + if (acceptState->listener_->close_requested()) { + throw OperationCancelled {}; + } + + acceptState->listener_->acceptor_on_driver().async_accept( + socket->socket_on_driver(), + std::move(completion)); + } + }; + + if (native.error_) { + if (native.cancellation_ != + detail::NativeCancellationOrigin::none) { + throw OperationCancelled {}; + } + + throw std::system_error { native.error_ }; + } + + if (!acceptState->socket_) { + std::terminate(); + } + + if (!acceptState->socket_->try_mark_open_on_driver()) { + throw OperationCancelled {}; + } + + stream.emplace(TcpStream { acceptState->socket_ }); + } catch (...) { + if (acceptState && acceptState->socket_) { + acceptState->socket_->close_on_driver(); + } + + exception = std::current_exception(); + } + + try { + co_await returnTo.schedule(); + } catch (...) { + releaseAdmission(); + throw; + } + + // Context 可在返回跳转期间关闭尚未发布的资源。 + if (!exception && !stream->is_open()) { + exception = std::make_exception_ptr(OperationCancelled {}); + } + + releaseAdmission(); + + if (exception) { + std::rethrow_exception(exception); + } + + co_return std::move(*stream); +} + template requires ( std::move_constructible && @@ -1020,4 +1566,41 @@ Task TcpStream::write_all( std::move(stopToken)); } +template +requires ( + std::move_constructible && + requires(const ReturnScheduler& scheduler) { + scheduler.schedule(); + } +) +Task TcpListener::bind( + IoContext& context, + ReturnScheduler returnTo, + std::string_view numericAddress, + std::uint16_t port, + std::stop_token stopToken) { + return bind_impl_( + context.state_, + std::move(returnTo), + std::string { numericAddress }, + port, + std::move(stopToken)); +} + +template +requires ( + std::move_constructible && + requires(const ReturnScheduler& scheduler) { + scheduler.schedule(); + } +) +Task TcpListener::accept( + ReturnScheduler returnTo, + std::stop_token stopToken) { + return accept_impl_( + state_, + std::move(returnTo), + std::move(stopToken)); +} + } // namespace mcpplibs::cmp diff --git a/tests/tcp_test.cpp b/tests/tcp_test.cpp index 485897d..aca3f87 100644 --- a/tests/tcp_test.cpp +++ b/tests/tcp_test.cpp @@ -11,6 +11,7 @@ using mcpplibs::cmp::OperationCancelled; using mcpplibs::cmp::RunLoop; using mcpplibs::cmp::Task; using mcpplibs::cmp::TaskGroup; +using mcpplibs::cmp::TcpListener; using mcpplibs::cmp::TcpStream; using mcpplibs::cmp::ThreadPool; @@ -35,6 +36,18 @@ static_assert(!std::is_move_assignable_v); static_assert(noexcept(std::declval().close())); static_assert(noexcept(std::declval().is_open())); +static_assert(!std::default_initializable); +static_assert(std::destructible); +static_assert(std::is_final_v); +static_assert(!std::copy_constructible); +static_assert(std::move_constructible); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(!std::is_copy_assignable_v); +static_assert(!std::is_move_assignable_v); +static_assert(noexcept(std::declval().close())); +static_assert(noexcept(std::declval().is_open())); +static_assert(noexcept(std::declval().local_port())); + class LoopbackServer final { public: using Protocol = std::function; @@ -411,6 +424,29 @@ Task occupy_worker( release.wait(false, std::memory_order_acquire); } +template +void close_concurrently(Resource& resource) { + constexpr std::size_t CLOSER_COUNT { 4 }; + std::barrier start { CLOSER_COUNT + 1 }; + std::vector closers {}; + closers.reserve(CLOSER_COUNT); + + for (std::size_t index {}; index < CLOSER_COUNT; ++index) { + closers.emplace_back([&resource, &start] { + start.arrive_and_wait(); + resource.close(); + }); + } + + start.arrive_and_wait(); + + for (auto& closer : closers) { + closer.join(); + } + + resource.close(); +} + struct WriteOverlapObservation final { bool workerEntered_ {}; bool rejected_ {}; @@ -592,8 +628,7 @@ Task close_pending_read( } catch (...) { } - stream.close(); - stream.close(); + close_concurrently(stream); co_await group.join(); co_return result; } @@ -838,6 +873,1327 @@ Task run_concurrent_echo_clients( co_return counts; } +enum class BindOutcome { + succeeded, + cancelled, + system_error +}; + +struct BindObservation final { + BindOutcome outcome_ {}; + std::thread::id resumedThread_ {}; +}; + +template +Task bind_and_observe( + IoContext& context, + ReturnScheduler returnTo, + std::string_view address, + std::uint16_t port) { + auto listener = co_await TcpListener::bind( + context, + std::move(returnTo), + address, + port); + co_return std::this_thread::get_id(); +} + +template +Task observe_bind_outcome( + IoContext& context, + ReturnScheduler returnTo, + std::string_view address, + std::uint16_t port, + std::stop_token stopToken = {}) { + try { + auto listener = co_await TcpListener::bind( + context, + std::move(returnTo), + address, + port, + std::move(stopToken)); + co_return BindObservation { + BindOutcome::succeeded, + std::this_thread::get_id() + }; + } catch (const OperationCancelled&) { + co_return BindObservation { + BindOutcome::cancelled, + std::this_thread::get_id() + }; + } catch (const std::system_error&) { + co_return BindObservation { + BindOutcome::system_error, + std::this_thread::get_id() + }; + } +} + +struct AcceptObservation final { + int completions_ {}; + bool succeeded_ {}; + bool cancelled_ {}; + bool logicError_ {}; + bool systemError_ {}; + bool unexpected_ {}; + std::thread::id resumedThread_ {}; +}; + +template +Task observe_accept( + TcpListener& listener, + ReturnScheduler returnTo, + AcceptObservation& observation, + std::stop_token stopToken = {}) { + try { + auto stream = co_await listener.accept( + std::move(returnTo), + std::move(stopToken)); + observation.succeeded_ = true; + } catch (const OperationCancelled&) { + observation.cancelled_ = true; + } catch (const std::logic_error&) { + observation.logicError_ = true; + } catch (const std::system_error&) { + observation.systemError_ = true; + } catch (...) { + observation.unexpected_ = true; + } + + ++observation.completions_; + observation.resumedThread_ = std::this_thread::get_id(); +} + +struct ListenerServerObservation final { + int completions_ {}; + bool succeeded_ {}; + bool cancelled_ {}; + bool error_ {}; + std::thread::id resumedThread_ {}; +}; + +Task accept_and_echo( + TcpListener& listener, + RunLoop::Scheduler returnTo, + bool closeListenerAfterAccept, + ListenerServerObservation& observation) { + try { + auto stream = co_await listener.accept(returnTo); + + if (closeListenerAfterAccept) { + listener.close(); + } + + std::array payload {}; + std::size_t received {}; + + while (received < payload.size()) { + const auto size = co_await stream.read_some( + returnTo, + std::span { payload }.subspan(received)); + + if (size == 0) { + throw std::runtime_error { "listener client closed early" }; + } + + received += size; + } + + co_await stream.write_all(returnTo, payload); + observation.succeeded_ = true; + } catch (const OperationCancelled&) { + observation.cancelled_ = true; + } catch (...) { + observation.error_ = true; + } + + ++observation.completions_; + observation.resumedThread_ = std::this_thread::get_id(); +} + +struct ListenerExchangeObservation final { + ListenerServerObservation server_ {}; + LoadClientObservation client_ {}; +}; + +Task run_listener_exchange( + IoContext& context, + TcpListener& listener, + RunLoop::Scheduler returnTo, + bool closeListenerAfterAccept = false) { + ListenerExchangeObservation observation {}; + TaskGroup group {}; + group.spawn(accept_and_echo( + listener, + returnTo, + closeListenerAfterAccept, + observation.server_)); + group.spawn(run_echo_client( + context, + returnTo, + listener.local_port(), + 7, + observation.client_)); + co_await group.join(); + co_return observation; +} + +Task connect_once_to( + IoContext& context, + RunLoop::Scheduler returnTo, + std::string_view numericAddress, + std::uint16_t port, + bool& succeeded) { + try { + auto stream = co_await TcpStream::connect( + context, + std::move(returnTo), + numericAddress, + port); + succeeded = true; + } catch (...) { + succeeded = false; + } +} + +struct AcceptConnectionObservation final { + AcceptObservation accepted_ {}; + bool connected_ {}; +}; + +Task run_accept_connection( + IoContext& context, + TcpListener& listener, + RunLoop::Scheduler returnTo, + std::string_view numericAddress) { + AcceptConnectionObservation result {}; + TaskGroup group {}; + group.spawn(observe_accept( + listener, + returnTo, + result.accepted_)); + group.spawn(connect_once_to( + context, + returnTo, + numericAddress, + listener.local_port(), + result.connected_)); + co_await group.join(); + co_return result; +} + +template +Task run_accept_connection_with_return( + IoContext& context, + TcpListener& listener, + AcceptScheduler acceptReturnTo, + RunLoop::Scheduler clientReturnTo) { + AcceptConnectionObservation result {}; + TaskGroup group {}; + group.spawn(observe_accept( + listener, + std::move(acceptReturnTo), + result.accepted_)); + group.spawn(connect_once_to( + context, + std::move(clientReturnTo), + "127.0.0.1", + listener.local_port(), + result.connected_)); + co_await group.join(); + co_return result; +} + +struct SignallingScheduler final { + ThreadPool::Scheduler scheduler_; + std::binary_semaphore* entered_ {}; + + [[nodiscard]] Task schedule() const { + entered_->release(); + co_await scheduler_.schedule(); + } +}; + +template +Task store_task_result(Task task, T& result) { + result = co_await std::move(task); +} + +struct BindPublicationObservation final { + bool workerEntered_ {}; + bool returnEntered_ {}; + BindObservation bind_ {}; +}; + +Task destroy_context_before_bind_publication( + std::unique_ptr& context, + ThreadPool::Scheduler gatedReturn) { + BindPublicationObservation result {}; + std::binary_semaphore workerEntered { 0 }; + std::binary_semaphore returnEntered { 0 }; + std::atomic releaseWorker { false }; + TaskGroup group {}; + group.spawn(occupy_worker( + gatedReturn, + workerEntered, + releaseWorker)); + result.workerEntered_ = workerEntered.try_acquire_for(2s); + + if (result.workerEntered_) { + group.spawn(store_task_result( + observe_bind_outcome( + *context, + SignallingScheduler { gatedReturn, &returnEntered }, + "127.0.0.1", + 0), + result.bind_)); + result.returnEntered_ = returnEntered.try_acquire_for(2s); + context.reset(); + } + + releaseWorker.store(true, std::memory_order_release); + releaseWorker.notify_all(); + co_await group.join(); + co_return result; +} + +struct TcpPublicationObservation final { + bool workerEntered_ {}; + bool returnEntered_ {}; + ConnectObservation connect_ {}; + AcceptObservation accept_ {}; +}; + +Task destroy_context_before_tcp_publication( + std::unique_ptr& context, + TcpListener& listener, + RunLoop::Scheduler caller, + ThreadPool::Scheduler gatedReturn) { + TcpPublicationObservation result {}; + std::binary_semaphore workerEntered { 0 }; + std::binary_semaphore returnEntered { 0 }; + std::atomic releaseWorker { false }; + TaskGroup group {}; + group.spawn(occupy_worker( + gatedReturn, + workerEntered, + releaseWorker)); + result.workerEntered_ = workerEntered.try_acquire_for(2s); + + if (result.workerEntered_) { + group.spawn(observe_accept( + listener, + SignallingScheduler { gatedReturn, &returnEntered }, + result.accept_)); + group.spawn(store_task_result( + observe_connect_outcome( + *context, + caller, + "127.0.0.1", + listener.local_port()), + result.connect_)); + result.returnEntered_ = returnEntered.try_acquire_for(2s); + context.reset(); + } + + releaseWorker.store(true, std::memory_order_release); + releaseWorker.notify_all(); + co_await group.join(); + co_return result; +} + +struct AcceptPublicationObservation final { + bool workerEntered_ {}; + bool returnEntered_ {}; + bool overlapRejected_ {}; + AcceptObservation accepted_ {}; + bool connected_ {}; + bool overlapConnected_ {}; +}; + +Task reject_accept_before_publication( + IoContext& context, + TcpListener& listener, + RunLoop::Scheduler caller, + ThreadPool::Scheduler gatedReturn) { + AcceptPublicationObservation result {}; + std::binary_semaphore workerEntered { 0 }; + std::binary_semaphore returnEntered { 0 }; + std::atomic releaseWorker { false }; + TaskGroup group {}; + group.spawn(occupy_worker( + gatedReturn, + workerEntered, + releaseWorker)); + result.workerEntered_ = workerEntered.try_acquire_for(2s); + + if (!result.workerEntered_) { + releaseWorker.store(true, std::memory_order_release); + releaseWorker.notify_all(); + co_await group.join(); + co_return result; + } + + group.spawn(observe_accept( + listener, + SignallingScheduler { gatedReturn, &returnEntered }, + result.accepted_)); + group.spawn(connect_once_to( + context, + caller, + "127.0.0.1", + listener.local_port(), + result.connected_)); + result.returnEntered_ = returnEntered.try_acquire_for(2s); + + if (result.returnEntered_) { + group.spawn(connect_once_to( + context, + caller, + "127.0.0.1", + listener.local_port(), + result.overlapConnected_)); + + try { + auto overlap = co_await listener.accept(caller); + } catch (const std::logic_error&) { + result.overlapRejected_ = true; + } catch (...) { + } + } else { + listener.close(); + } + + releaseWorker.store(true, std::memory_order_release); + releaseWorker.notify_all(); + co_await group.join(); + co_return result; +} + +struct AcceptCancellationObservation final { + AcceptObservation cancelled_ {}; + AcceptObservation reused_ {}; + bool overlapRejected_ {}; + bool reconnectSucceeded_ {}; +}; + +Task cancel_pending_accept( + IoContext& context, + TcpListener& listener, + RunLoop::Scheduler returnTo, + std::stop_source& stopSource) { + AcceptCancellationObservation result {}; + TaskGroup pending {}; + pending.spawn(observe_accept( + listener, + returnTo, + result.cancelled_, + stopSource.get_token())); + + try { + auto overlap = co_await listener.accept(returnTo); + } catch (const std::logic_error&) { + result.overlapRejected_ = true; + } catch (...) { + } + + stopSource.request_stop(); + co_await pending.join(); + + TaskGroup reuse {}; + reuse.spawn(observe_accept( + listener, + returnTo, + result.reused_)); + reuse.spawn(connect_once_to( + context, + returnTo, + "127.0.0.1", + listener.local_port(), + result.reconnectSucceeded_)); + co_await reuse.join(); + co_return result; +} + +struct CloseAcceptObservation final { + AcceptObservation operation_ {}; + bool overlapRejected_ {}; +}; + +Task close_pending_accept( + TcpListener& listener, + RunLoop::Scheduler returnTo) { + CloseAcceptObservation result {}; + TaskGroup group {}; + group.spawn(observe_accept( + listener, + returnTo, + result.operation_)); + + try { + auto overlap = co_await listener.accept(returnTo); + } catch (const std::logic_error&) { + result.overlapRejected_ = true; + } catch (...) { + } + + close_concurrently(listener); + co_await group.join(); + co_return result; +} + +Task destroy_context_with_pending_accept( + std::unique_ptr& context, + TcpListener& listener, + RunLoop::Scheduler returnTo) { + AcceptObservation result {}; + TaskGroup group {}; + group.spawn(observe_accept( + listener, + returnTo, + result)); + + std::jthread destroyer { [&context] { + context.reset(); + } }; + co_await group.join(); + destroyer.join(); + co_return result; +} + +struct AcceptRaceObservation final { + AcceptObservation operation_ {}; + bool connected_ {}; +}; + +Task run_close_accept_race( + TcpListener& listener, + RunLoop::Scheduler returnTo) { + AcceptRaceObservation result {}; + TaskGroup group {}; + group.spawn(observe_accept( + listener, + returnTo, + result.operation_)); + + std::barrier start { 2 }; + const auto port = listener.local_port(); + std::jthread connector { [&start, port, &result] { + start.arrive_and_wait(); + asio::io_context ioContext {}; + asio::ip::tcp::socket socket { ioContext }; + std::error_code error {}; + socket.connect( + asio::ip::tcp::endpoint { + asio::ip::address_v4::loopback(), + port + }, + error); + result.connected_ = !error; + } }; + std::jthread closer { [&listener, &start] { + start.arrive_and_wait(); + listener.close(); + } }; + + co_await group.join(); + connector.join(); + closer.join(); + co_return result; +} + +struct AcceptRaceCounts final { + int succeeded_ {}; + int cancelled_ {}; + int invalid_ {}; + int connectionErrors_ {}; +}; + +Task run_stop_accept_races( + TcpListener& listener, + RunLoop::Scheduler returnTo, + int raceCount) { + AcceptRaceCounts counts {}; + + for (int iteration { 0 }; iteration < raceCount; ++iteration) { + std::stop_source stopSource {}; + AcceptObservation operation {}; + TaskGroup group {}; + group.spawn(observe_accept( + listener, + returnTo, + operation, + stopSource.get_token())); + + std::barrier start { 2 }; + std::atomic connected { false }; + const auto port = listener.local_port(); + std::jthread connector { [&start, &connected, port] { + start.arrive_and_wait(); + asio::io_context ioContext {}; + asio::ip::tcp::socket socket { ioContext }; + std::error_code error {}; + socket.connect( + asio::ip::tcp::endpoint { + asio::ip::address_v4::loopback(), + port + }, + error); + connected.store(!error, std::memory_order_release); + } }; + std::jthread stopper { [&start, &stopSource] { + start.arrive_and_wait(); + stopSource.request_stop(); + } }; + + co_await group.join(); + connector.join(); + stopper.join(); + + counts.connectionErrors_ += !connected.load( + std::memory_order_acquire); + + if (operation.completions_ != 1 || + operation.logicError_ || + operation.systemError_ || + operation.unexpected_) { + ++counts.invalid_; + } else if (operation.succeeded_) { + ++counts.succeeded_; + } else if (operation.cancelled_) { + ++counts.cancelled_; + } else { + ++counts.invalid_; + } + } + + co_return counts; +} + +struct ListenerLoadServerObservation final { + int completions_ {}; + bool succeeded_ {}; + bool error_ {}; + std::thread::id resumedThread_ {}; +}; + +Task echo_accepted_stream( + TcpStream stream, + RunLoop::Scheduler returnTo, + ListenerLoadServerObservation& observation) { + try { + std::array payload {}; + std::size_t received {}; + + while (received < payload.size()) { + const auto size = co_await stream.read_some( + returnTo, + std::span { payload }.subspan(received)); + + if (size == 0) { + throw std::runtime_error { "load client closed early" }; + } + + received += size; + } + + co_await stream.write_all(returnTo, payload); + observation.succeeded_ = true; + } catch (...) { + observation.error_ = true; + } + + ++observation.completions_; + observation.resumedThread_ = std::this_thread::get_id(); +} + +struct ListenerAcceptLoopObservation final { + int accepted_ {}; + int errors_ {}; + int wrongThread_ {}; +}; + +Task accept_echo_clients( + TcpListener& listener, + RunLoop::Scheduler returnTo, + std::vector& observations, + ListenerAcceptLoopObservation& acceptLoop) { + const auto expectedThread = std::this_thread::get_id(); + TaskGroup handlers {}; + + try { + for (auto& observation : observations) { + auto stream = co_await listener.accept(returnTo); + ++acceptLoop.accepted_; + acceptLoop.wrongThread_ += + std::this_thread::get_id() != expectedThread; + handlers.spawn(echo_accepted_stream( + std::move(stream), + returnTo, + observation)); + } + } catch (...) { + ++acceptLoop.errors_; + listener.close(); + } + + co_await handlers.join(); +} + +struct ListenerLoadCounts final { + LoadCounts clients_ {}; + int accepted_ {}; + int acceptErrors_ {}; + int acceptWrongThread_ {}; + int serverCompletions_ {}; + int serverSuccesses_ {}; + int serverErrors_ {}; + int serverWrongThread_ {}; +}; + +Task run_listener_load( + IoContext& context, + TcpListener& listener, + RunLoop::Scheduler returnTo, + std::size_t clientCount) { + const auto expectedThread = std::this_thread::get_id(); + std::vector server(clientCount); + std::vector clients(clientCount); + ListenerAcceptLoopObservation acceptLoop {}; + TaskGroup group {}; + group.spawn(accept_echo_clients( + listener, + returnTo, + server, + acceptLoop)); + + for (std::size_t index {}; index < clientCount; ++index) { + group.spawn(run_echo_client( + context, + returnTo, + listener.local_port(), + index, + clients[index])); + } + + co_await group.join(); + + ListenerLoadCounts counts {}; + counts.accepted_ = acceptLoop.accepted_; + counts.acceptErrors_ = acceptLoop.errors_; + counts.acceptWrongThread_ = acceptLoop.wrongThread_; + + for (const auto& observation : server) { + counts.serverCompletions_ += observation.completions_; + counts.serverSuccesses_ += observation.succeeded_; + counts.serverErrors_ += observation.error_; + counts.serverWrongThread_ += + observation.resumedThread_ != expectedThread; + } + + for (const auto& observation : clients) { + counts.clients_.completions_ += observation.completions_; + counts.clients_.invalidCompletions_ += + observation.completions_ != 1; + counts.clients_.wrongThread_ += + observation.resumedThread_ != expectedThread; + + switch (observation.outcome_) { + case LoadOutcome::succeeded: + ++counts.clients_.succeeded_; + break; + case LoadOutcome::cancelled: + ++counts.clients_.cancelled_; + break; + case LoadOutcome::error: + ++counts.clients_.errors_; + break; + } + } + + co_return counts; +} + +TEST(CmpTcpTest, ListenerBindIsLazyAndOwnsTemporaryAddress) { + auto reservation = std::make_unique(); + const auto port = reservation->port(); + IoContext context {}; + RunLoop loop {}; + auto task = TcpListener::bind( + context, + loop.get_scheduler(), + std::string { "127.0.0.1" }, + port); + + reservation.reset(); + auto listener = loop.run(std::move(task)); + + EXPECT_TRUE(listener.is_open()); + EXPECT_EQ(listener.local_port(), port); +} + +TEST(CmpTcpTest, ListenerBindTaskRejectsExpiredContext) { + RunLoop loop {}; + std::optional> bindTask {}; + + { + IoContext context {}; + bindTask.emplace(TcpListener::bind( + context, + loop.get_scheduler(), + "127.0.0.1", + 0)); + } + + EXPECT_THROW( + loop.run(std::move(*bindTask)), + std::logic_error); +} + +TEST(CmpTcpTest, ListenerAcceptsIpv4AndStreamSurvivesListenerClose) { + IoContext context {}; + RunLoop loop {}; + const auto callerThread = std::this_thread::get_id(); + auto listener = loop.run(TcpListener::bind( + context, + loop.get_scheduler(), + "127.0.0.1", + 0)); + + ASSERT_TRUE(listener.is_open()); + ASSERT_NE(listener.local_port(), 0); + const auto port = listener.local_port(); + const auto observation = loop.run(run_listener_exchange( + context, + listener, + loop.get_scheduler(), + true)); + + EXPECT_FALSE(listener.is_open()); + EXPECT_EQ(listener.local_port(), port); + EXPECT_EQ(observation.server_.completions_, 1); + EXPECT_TRUE(observation.server_.succeeded_); + EXPECT_FALSE(observation.server_.cancelled_); + EXPECT_FALSE(observation.server_.error_); + EXPECT_EQ(observation.server_.resumedThread_, callerThread); + EXPECT_EQ(observation.client_.completions_, 1); + EXPECT_EQ(observation.client_.outcome_, LoadOutcome::succeeded); + EXPECT_EQ(observation.client_.resumedThread_, callerThread); +} + +TEST(CmpTcpTest, ListenerAcceptsIpv6WhenLoopbackIsAvailable) { + { + LoopbackServer capability { asio::ip::address_v6::loopback() }; + + if (!capability.available()) { + GTEST_SKIP() << "IPv6 loopback unavailable: " + << capability.start_error().message(); + } + } + + IoContext context {}; + RunLoop loop {}; + auto listener = loop.run(TcpListener::bind( + context, + loop.get_scheduler(), + "::1", + 0)); + const auto observation = loop.run(run_accept_connection( + context, + listener, + loop.get_scheduler(), + "::1")); + + EXPECT_TRUE(observation.connected_); + EXPECT_EQ(observation.accepted_.completions_, 1); + EXPECT_TRUE(observation.accepted_.succeeded_); + EXPECT_EQ(observation.accepted_.resumedThread_, + std::this_thread::get_id()); +} + +TEST(CmpTcpTest, ListenerReportsBindErrorsOnReturnScheduler) { + NonListeningEndpoint occupied {}; + IoContext context {}; + RunLoop loop {}; + const auto callerThread = std::this_thread::get_id(); + + const auto invalidAddress = loop.run(observe_bind_outcome( + context, + loop.get_scheduler(), + "not-an-address", + 0)); + EXPECT_EQ(invalidAddress.outcome_, BindOutcome::system_error); + EXPECT_EQ(invalidAddress.resumedThread_, callerThread); + + const auto addressInUse = loop.run(observe_bind_outcome( + context, + loop.get_scheduler(), + "127.0.0.1", + occupied.port())); + EXPECT_EQ(addressInUse.outcome_, BindOutcome::system_error); + EXPECT_EQ(addressInUse.resumedThread_, callerThread); +} + +TEST(CmpTcpTest, ListenerPreCancellationSkipsInvalidAddressParsing) { + IoContext context {}; + RunLoop loop {}; + std::stop_source stopSource {}; + stopSource.request_stop(); + + const auto observation = loop.run(observe_bind_outcome( + context, + loop.get_scheduler(), + "not-an-address", + 0, + stopSource.get_token())); + + EXPECT_EQ(observation.outcome_, BindOutcome::cancelled); + EXPECT_EQ(observation.resumedThread_, std::this_thread::get_id()); +} + +TEST(CmpTcpTest, ListenerCanReturnThroughThreadPoolScheduler) { + IoContext context {}; + ThreadPool returnWorkers { 1 }; + RunLoop loop {}; + const auto callerThread = std::this_thread::get_id(); + const auto resumedThread = loop.run(bind_and_observe( + context, + returnWorkers.get_scheduler(), + "127.0.0.1", + 0)); + + EXPECT_NE(resumedThread, callerThread); +} + +TEST(CmpTcpTest, ListenerInvalidReturnSchedulerClosesBoundSocket) { + auto reservation = std::make_unique(); + const auto port = reservation->port(); + reservation.reset(); + + IoContext context {}; + RunLoop driver {}; + RunLoop inactive {}; + + EXPECT_THROW( + driver.run(TcpListener::bind( + context, + inactive.get_scheduler(), + "127.0.0.1", + port)), + std::logic_error); + + auto listener = driver.run(TcpListener::bind( + context, + driver.get_scheduler(), + "127.0.0.1", + port)); + EXPECT_TRUE(listener.is_open()); + EXPECT_EQ(listener.local_port(), port); +} + +TEST(CmpTcpTest, ListenerAcceptCanReturnThroughThreadPoolScheduler) { + IoContext context {}; + ThreadPool returnWorkers { 1 }; + RunLoop loop {}; + auto listener = loop.run(TcpListener::bind( + context, + loop.get_scheduler(), + "127.0.0.1", + 0)); + const auto observation = loop.run(run_accept_connection_with_return( + context, + listener, + returnWorkers.get_scheduler(), + loop.get_scheduler())); + + EXPECT_TRUE(observation.connected_); + EXPECT_EQ(observation.accepted_.completions_, 1); + EXPECT_TRUE(observation.accepted_.succeeded_); + EXPECT_NE(observation.accepted_.resumedThread_, + std::this_thread::get_id()); + EXPECT_TRUE(listener.is_open()); +} + +TEST(CmpTcpTest, ListenerInvalidAcceptReturnSchedulerKeepsItUsable) { + IoContext context {}; + RunLoop driver {}; + RunLoop inactive {}; + auto listener = driver.run(TcpListener::bind( + context, + driver.get_scheduler(), + "127.0.0.1", + 0)); + const auto observation = driver.run( + run_accept_connection_with_return( + context, + listener, + inactive.get_scheduler(), + driver.get_scheduler())); + + EXPECT_TRUE(observation.connected_); + EXPECT_EQ(observation.accepted_.completions_, 1); + EXPECT_FALSE(observation.accepted_.succeeded_); + EXPECT_TRUE(observation.accepted_.logicError_); + EXPECT_FALSE(observation.accepted_.cancelled_); + EXPECT_FALSE(observation.accepted_.systemError_); + EXPECT_FALSE(observation.accepted_.unexpected_); + EXPECT_TRUE(listener.is_open()); + + const auto reuse = driver.run(run_accept_connection( + context, + listener, + driver.get_scheduler(), + "127.0.0.1")); + EXPECT_TRUE(reuse.connected_); + EXPECT_TRUE(reuse.accepted_.succeeded_); +} + +TEST(CmpTcpTest, ListenerRejectsOverlapUntilAcceptedStreamIsPublished) { + IoContext context {}; + ThreadPool gatedReturn { 1 }; + RunLoop loop {}; + auto listener = loop.run(TcpListener::bind( + context, + loop.get_scheduler(), + "127.0.0.1", + 0)); + const auto observation = loop.run(reject_accept_before_publication( + context, + listener, + loop.get_scheduler(), + gatedReturn.get_scheduler())); + + EXPECT_TRUE(observation.workerEntered_); + EXPECT_TRUE(observation.returnEntered_); + EXPECT_TRUE(observation.overlapRejected_); + EXPECT_TRUE(observation.connected_); + EXPECT_TRUE(observation.overlapConnected_); + EXPECT_EQ(observation.accepted_.completions_, 1); + EXPECT_TRUE(observation.accepted_.succeeded_); + EXPECT_FALSE(observation.accepted_.cancelled_); + EXPECT_FALSE(observation.accepted_.logicError_); + EXPECT_FALSE(observation.accepted_.systemError_); + EXPECT_FALSE(observation.accepted_.unexpected_); + EXPECT_NE(observation.accepted_.resumedThread_, + std::this_thread::get_id()); + EXPECT_TRUE(listener.is_open()); +} + +TEST(CmpTcpTest, ListenerPreCancelledAcceptLeavesItUsable) { + IoContext context {}; + RunLoop loop {}; + std::stop_source stopSource {}; + stopSource.request_stop(); + auto listener = loop.run(TcpListener::bind( + context, + loop.get_scheduler(), + "127.0.0.1", + 0)); + + EXPECT_THROW( + loop.run(listener.accept( + loop.get_scheduler(), + stopSource.get_token())), + OperationCancelled); + EXPECT_TRUE(listener.is_open()); + + const auto reuse = loop.run(run_accept_connection( + context, + listener, + loop.get_scheduler(), + "127.0.0.1")); + EXPECT_TRUE(reuse.connected_); + EXPECT_TRUE(reuse.accepted_.succeeded_); +} + +TEST(CmpTcpTest, ListenerCancelsPendingAcceptAndCanAcceptAgain) { + IoContext context {}; + RunLoop loop {}; + std::stop_source stopSource {}; + auto listener = loop.run(TcpListener::bind( + context, + loop.get_scheduler(), + "127.0.0.1", + 0)); + const auto observation = loop.run(cancel_pending_accept( + context, + listener, + loop.get_scheduler(), + stopSource)); + + EXPECT_TRUE(observation.overlapRejected_); + EXPECT_EQ(observation.cancelled_.completions_, 1); + EXPECT_TRUE(observation.cancelled_.cancelled_); + EXPECT_FALSE(observation.cancelled_.succeeded_); + EXPECT_FALSE(observation.cancelled_.logicError_); + EXPECT_FALSE(observation.cancelled_.systemError_); + EXPECT_FALSE(observation.cancelled_.unexpected_); + EXPECT_EQ(observation.cancelled_.resumedThread_, + std::this_thread::get_id()); + EXPECT_TRUE(observation.reconnectSucceeded_); + EXPECT_EQ(observation.reused_.completions_, 1); + EXPECT_TRUE(observation.reused_.succeeded_); + EXPECT_TRUE(listener.is_open()); +} + +TEST(CmpTcpTest, ListenerCloseCancelsPendingAcceptAndIsIdempotent) { + IoContext context {}; + RunLoop loop {}; + auto listener = loop.run(TcpListener::bind( + context, + loop.get_scheduler(), + "127.0.0.1", + 0)); + const auto port = listener.local_port(); + const auto observation = loop.run(close_pending_accept( + listener, + loop.get_scheduler())); + + EXPECT_TRUE(observation.overlapRejected_); + EXPECT_EQ(observation.operation_.completions_, 1); + EXPECT_TRUE(observation.operation_.cancelled_); + EXPECT_FALSE(observation.operation_.succeeded_); + EXPECT_FALSE(observation.operation_.logicError_); + EXPECT_FALSE(observation.operation_.systemError_); + EXPECT_FALSE(observation.operation_.unexpected_); + EXPECT_FALSE(listener.is_open()); + EXPECT_EQ(listener.local_port(), port); + EXPECT_NO_THROW(listener.close()); + EXPECT_THROW( + loop.run(listener.accept(loop.get_scheduler())), + std::logic_error); +} + +TEST(CmpTcpTest, ListenerMoveTransfersCloseOwnership) { + IoContext context {}; + RunLoop loop {}; + auto listener = loop.run(TcpListener::bind( + context, + loop.get_scheduler(), + "127.0.0.1", + 0)); + const auto port = listener.local_port(); + TcpListener moved { std::move(listener) }; + + EXPECT_FALSE(listener.is_open()); + EXPECT_EQ(listener.local_port(), 0); + EXPECT_NO_THROW(listener.close()); + EXPECT_THROW( + loop.run(listener.accept(loop.get_scheduler())), + std::logic_error); + EXPECT_TRUE(moved.is_open()); + EXPECT_EQ(moved.local_port(), port); + + moved.close(); + EXPECT_FALSE(moved.is_open()); + EXPECT_EQ(moved.local_port(), port); +} + +TEST(CmpTcpTest, ListenerAcceptTaskSurvivesHandleMove) { + IoContext context {}; + RunLoop loop {}; + auto listener = loop.run(TcpListener::bind( + context, + loop.get_scheduler(), + "127.0.0.1", + 0)); + const auto port = listener.local_port(); + auto acceptTask = listener.accept(loop.get_scheduler()); + TcpListener moved { std::move(listener) }; + bool connected {}; + std::jthread connector { [port, &connected] { + asio::io_context ioContext {}; + asio::ip::tcp::socket socket { ioContext }; + std::error_code error {}; + socket.connect( + asio::ip::tcp::endpoint { + asio::ip::address_v4::loopback(), + port + }, + error); + connected = !error; + } }; + + auto stream = loop.run(std::move(acceptTask)); + connector.join(); + + EXPECT_TRUE(connected); + EXPECT_TRUE(stream.is_open()); + EXPECT_TRUE(moved.is_open()); +} + +TEST(CmpTcpTest, ListenerContextShutdownDrainsPendingAccept) { + auto context = std::make_unique(); + RunLoop loop {}; + auto listener = loop.run(TcpListener::bind( + *context, + loop.get_scheduler(), + "127.0.0.1", + 0)); + const auto port = listener.local_port(); + const auto observation = loop.run( + destroy_context_with_pending_accept( + context, + listener, + loop.get_scheduler())); + + EXPECT_FALSE(context); + EXPECT_EQ(observation.completions_, 1); + EXPECT_TRUE(observation.cancelled_); + EXPECT_FALSE(observation.succeeded_); + EXPECT_FALSE(observation.logicError_); + EXPECT_FALSE(observation.systemError_); + EXPECT_FALSE(observation.unexpected_); + EXPECT_EQ(observation.resumedThread_, std::this_thread::get_id()); + EXPECT_FALSE(listener.is_open()); + EXPECT_EQ(listener.local_port(), port); + EXPECT_THROW( + loop.run(listener.accept(loop.get_scheduler())), + std::logic_error); + EXPECT_NO_THROW(listener.close()); +} + +TEST(CmpTcpTest, ContextShutdownRejectsUnpublishedTcpResources) { + RunLoop loop {}; + const auto callerThread = std::this_thread::get_id(); + + { + auto context = std::make_unique(); + ThreadPool gatedReturn { 1 }; + const auto observation = loop.run( + destroy_context_before_bind_publication( + context, + gatedReturn.get_scheduler())); + + EXPECT_FALSE(context); + EXPECT_TRUE(observation.workerEntered_); + EXPECT_TRUE(observation.returnEntered_); + EXPECT_EQ(observation.bind_.outcome_, BindOutcome::cancelled); + EXPECT_NE(observation.bind_.resumedThread_, callerThread); + } + + { + auto context = std::make_unique(); + ThreadPool gatedReturn { 1 }; + auto listener = loop.run(TcpListener::bind( + *context, + loop.get_scheduler(), + "127.0.0.1", + 0)); + const auto observation = loop.run( + destroy_context_before_tcp_publication( + context, + listener, + loop.get_scheduler(), + gatedReturn.get_scheduler())); + + EXPECT_FALSE(context); + EXPECT_TRUE(observation.workerEntered_); + EXPECT_TRUE(observation.returnEntered_); + EXPECT_EQ(observation.connect_.outcome_, ConnectOutcome::cancelled); + EXPECT_EQ(observation.connect_.resumedThread_, callerThread); + EXPECT_EQ(observation.accept_.completions_, 1); + EXPECT_TRUE(observation.accept_.cancelled_); + EXPECT_FALSE(observation.accept_.succeeded_); + EXPECT_FALSE(observation.accept_.logicError_); + EXPECT_FALSE(observation.accept_.systemError_); + EXPECT_FALSE(observation.accept_.unexpected_); + EXPECT_NE(observation.accept_.resumedThread_, callerThread); + EXPECT_FALSE(listener.is_open()); + } +} + +TEST(CmpTcpTest, ListenerCloseCompletionRaceChoosesOneOutcome) { + constexpr int RACE_COUNT { 20 }; + IoContext context {}; + RunLoop loop {}; + int succeeded {}; + int cancelled {}; + int invalid {}; + + for (int iteration { 0 }; iteration < RACE_COUNT; ++iteration) { + auto listener = loop.run(TcpListener::bind( + context, + loop.get_scheduler(), + "127.0.0.1", + 0)); + const auto observation = loop.run(run_close_accept_race( + listener, + loop.get_scheduler())); + + if (observation.operation_.completions_ != 1 || + observation.operation_.logicError_ || + observation.operation_.systemError_ || + observation.operation_.unexpected_) { + ++invalid; + } else if (observation.operation_.succeeded_) { + ++succeeded; + } else if (observation.operation_.cancelled_) { + ++cancelled; + } else { + ++invalid; + } + + EXPECT_FALSE(listener.is_open()); + } + + EXPECT_EQ(succeeded + cancelled, RACE_COUNT); + EXPECT_EQ(invalid, 0); +} + +TEST(CmpTcpTest, ListenerStopCompletionRaceChoosesOneOutcome) { + constexpr int RACE_COUNT { 100 }; + IoContext context {}; + RunLoop loop {}; + auto listener = loop.run(TcpListener::bind( + context, + loop.get_scheduler(), + "127.0.0.1", + 0)); + const auto counts = loop.run(run_stop_accept_races( + listener, + loop.get_scheduler(), + RACE_COUNT)); + + EXPECT_EQ(counts.succeeded_ + counts.cancelled_, RACE_COUNT); + EXPECT_EQ(counts.invalid_, 0); + EXPECT_EQ(counts.connectionErrors_, 0); + EXPECT_TRUE(listener.is_open()); +} + +TEST(CmpTcpTest, ListenerManyConcurrentClientsCompleteExactlyOnce) { + constexpr std::size_t CLIENT_COUNT { 32 }; + IoContext context {}; + RunLoop loop {}; + auto listener = loop.run(TcpListener::bind( + context, + loop.get_scheduler(), + "127.0.0.1", + 0)); + const auto counts = loop.run(run_listener_load( + context, + listener, + loop.get_scheduler(), + CLIENT_COUNT)); + + EXPECT_EQ(counts.accepted_, static_cast(CLIENT_COUNT)); + EXPECT_EQ(counts.acceptErrors_, 0); + EXPECT_EQ(counts.acceptWrongThread_, 0); + EXPECT_EQ(counts.serverCompletions_, static_cast(CLIENT_COUNT)); + EXPECT_EQ(counts.serverSuccesses_, static_cast(CLIENT_COUNT)); + EXPECT_EQ(counts.serverErrors_, 0); + EXPECT_EQ(counts.serverWrongThread_, 0); + EXPECT_EQ(counts.clients_.completions_, + static_cast(CLIENT_COUNT)); + EXPECT_EQ(counts.clients_.succeeded_, + static_cast(CLIENT_COUNT)); + EXPECT_EQ(counts.clients_.cancelled_, 0); + EXPECT_EQ(counts.clients_.errors_, 0); + EXPECT_EQ(counts.clients_.invalidCompletions_, 0); + EXPECT_EQ(counts.clients_.wrongThread_, 0); + EXPECT_TRUE(listener.is_open()); +} + TEST(CmpTcpTest, IoContextStartsAndStopsCleanly) { IoContext context {}; }