diff --git a/.agents/docs/2026-08-06-add-websocket-plan.md b/.agents/docs/2026-08-06-add-websocket-plan.md new file mode 100644 index 00000000..73c858df --- /dev/null +++ b/.agents/docs/2026-08-06-add-websocket-plan.md @@ -0,0 +1,105 @@ +# 新增 compat.websocket 12.0.1(2026-08-06) + +产出:`compat.websocket@12.0.1`(IXWebSocket,上游 MIT),32 个 client TU 的 header-compat +Form B 包,workspace 成员 `tests/examples/websocket`,以及一份自带 echo server 的离线测试。 + +## 1. 形态判定:C++ 源码 compat,纯客户端,零依赖 + +IXWebSocket 上游**没有 mcpp 支持**(CMake 构建、C++11、`#include ` +的头文件式消费),属第三方上游库 → Form B。上游源码是扁平的 `ixwebsocket/` 目录,头文件与源码同处一室; +`include_dirs = { "*" }` 把 verdir 根暴露出去,消费端 include 路径与上游安装布局一致 +(`/ixwebsocket/…`)。 + +**编译开关三项全关,是零外部依赖的关键**: + +- **TLS 关**(不定义 `IXWEBSOCKET_USE_TLS`):TLS socket 三组 TU(OpenSSL/MbedTLS/AppleSSL)根本不编入; + `IXSocketFactory.cpp` 只在 `#ifdef IXWEBSOCKET_USE_TLS` 下引用它们。计划稿"关掉 TLS 就不拉 OpenSSL"由此兑现。 +- **zlib 关**(不定义 `IXWEBSOCKET_USE_ZLIB`):`IXGzipCodec.cpp` 与 per-message-deflate codec 的所有 + zlib 调用都包在这个宏后,不定义则编译为透传 no-op —— 客户端在握手里不再提议压缩扩展,是保守且合法的默认。 +- **server 不提供**(见下节)。 + +**源列表 = 上游 CMake `IXWEBSOCKET_SOURCES` 的 36 项去掉 4 个 server TU**,余 32 项逐条列出 +(与 `compat.zlib`/`compat.protobuf` 的显式列法一致;`compat.abseil` 用通配是因为上游文件命名可裁剪, +这里没有可裁剪的通配,显式列反而可审)。 + +## 2. server TU 排除有链接层论证,不是拍脑袋 + +剔除的四个 TU:`IXWebSocketServer.cpp`、`IXWebSocketProxyServer.cpp`、`IXHttpServer.cpp`、 +`IXSocketServer.cpp`。排除前先在整个 client 侧做了符号引用检查 —— 除这 4 个文件自身外,**没有任何 +保留的 `.cpp` 引用这些类名**(`grep -l 'IXWebSocketServer\|IXHttpServer\|IXSocketServer\|IXWebSocketProxyServer' +` 只命中那 4 个文件自己)。这是"client 不反依赖 server"的直接证据,链接期不会 undefined reference。 + +`IXGetFreePort.cpp` **保留**:它是独立工具(找空闲端口),不属于 server 那一侧,且 32 个保留 TU 的 +basename 全局唯一,不会触发 mcpp#233/#240 的 flat-obj 撞名。 + +## 3. 关键决定(含两处与计划稿的偏离) + +| 项 | 计划稿 | 本包实际 | 理由 | +|---|---|---|---| +| 版本 | 未指定 | `12.0.1` | 上游最新稳定 tag(`git ls-remote --tags` 取 `sort -V \| tail`) | +| 目录 | `pkgs/w/` | **`pkgs/c/`** | 仓库约定是**完整包名首字母**(`compat.*` → `c/`,见 skill 与 docs);`pkgs/e/` 的 `compat.eui-neo` 是唯一用短名首字母的例外。放错目录会让本地 path index 报 not found | +| `language` | `c++17` | **`c++23`** | 全仓 59 个描述符统一 `c++23`(本仓 floor);IXWebSocket 是 C++11 源码,在 CI 工具链 clang 22.1.8 的 `c++23` 下 **32/32 TU 零错误**通过,无 char8_t/弃用 API 问题,没必要为它开 c++17 特例 | +| CN 镜像 | GLOBAL+CN 双 mirror | **plain-string url** | 本机无 `gtc`/gitcode 配置、无 `mcpp-res` 写权限,按 [docs/cn-mirror.md](../../docs/cn-mirror.md) 回退为纯字符串上游 url(与 `eui-neo` 0.5.5 同做法);sha 不受影响,维护者有权限后补 `{ GLOBAL, CN }` 即可 | + +## 4. 平台开关 + +- linux:`-lpthread`(上游 `Threads::Threads`);无 `-ldl`(全库无 `dlopen`/`dlsym`)。 +- macosx:`-lpthread`;TLS 关则无需 Foundation/Security。 +- windows:`-lws2_32 -lwsock32` + `-D_CRT_SECURE_NO_WARNINGS`。`shlwapi` **不链**:它只被剔除的 + `IXSocketOpenSSL.cpp` 用到(其 `PathFileExists` 等);保留的 TU 里没有任何 shlwapi 引用。`NOMINMAX` + 也不需要:唯一 include `windows.h` 的保留 TU 是 `IXSetThreadName.cpp`,其源码不用 min/max。 + +## 5. 测试设计:自带最小 RFC 6455 echo server,全程离线 + +包只编 client,测试要实测"握手/掩码/分片/关闭"就不能依赖外部服务。`tests/examples/websocket/tests/ws_test.cpp` +在**进程内**用原始 loopback socket 起了一个最小 echo server,并刻意做成**与被测库相互独立**: + +- `Sec-WebSocket-Accept` 用测试自带的 SHA-1 + base64 计算,`HTTP/1.1 101` 响应手写; +- 帧协议手写(掩码位/长度扩展/控制帧),server 端能验证**客户端的掩码位确实置位**; +- 收到特殊载荷 `FRAG` 时回一条**分片消息**(text FIN=0 + 两个 continuation),验证客户端重组; +- 收到 `PINGME` 时回一条 ping,验证客户端默认 `enablePong` 的自动 pong; +- close 回显 code 后关闭连接。 + +断言 11 项,全部带超时:`initNetSystem`、握手打开、text echo、**客户端掩码位**、binary echo 逐字节、 +客户端 ping→pong、服务端 ping→客户端自动 pong、分片重组、分片帧先于重组消息、close 握手完成、 +server 收到 close。CI 三平台离线可跑,不依赖 runner 网络。 + +**两个实测踩坑**(都已修进最终测试): + +1. **IXWebSocket 的 `_automaticReconnection` 默认是 `true`**(构造函数 `_automaticReconnection(true)`), + 不是关闭。close 握手完成后状态转 `Closed`,若测试在收到 Close 消息与 `ws.stop()` 之间留空窗, + run 循环会**再次 `checkConnection` 并重连** —— 而本测试的 server 线程在 close 后已退出,重连的 + 握手读响应会永久阻塞,表现为进程挂死。修法:close 测试直接调 `ws.stop()`(它内部先 `close()` 再置 + `_stop`),run 循环因 `_stop` 而退出,Close 消息在 `stop()` 返回前已投递到 collector。 +2. **"客户端掩码位"断言过早**:Open 消息之后客户端还没发过任何数据帧,server 看不到掩码位。把该断言 + 挪到 text echo 之后(echo 成功 = server 必然已收到客户端数据帧)。 + +## 6. 验证结论 + +两轮验证都通过: + +**与 CI 完全一致的一轮(mcpp 2026.8.3.3 + gcc@16.1.0 + `MCPP_INDEX_MIRROR=GLOBAL` + +`MCPP_BUILD_CACHE=local`,冷删 `target/`/`.mcpp/`):** + +``` +mcpp xpkg parse pkgs/c/compat.websocket.lua → parse OK(compat.websocket / 三平台 12.0.1 / + c++23 / import_std=false / 32 sources / target websocket) +mcpp test -p websocket → test result ok. 1 passed; 0 failed +``` + +该轮实际下载了 CI 的 gcc@16.1.0 工具链并以之编译 compat.websocket 的 32 个 TU,再编译并运行 +`ws_test` —— **11 项断言全部通过**,`ALL WEBSOCKET ASSERTIONS PASSED`,exit 0。 + +**独立的一轮(clang 22.1.8,standalone):** 32/32 TU 在 `c++23` 下逐 TU 编译零错误;同一测试二进制的 +11 项断言全过。 + +lint:`lua` 语法、`check_mirror_urls`(plain-string url 不触发镜像约束)、`check_package_name` 全过; +`mcpp xpkg parse` 在本地较新 mcpp(2026.8.4.1)与 CI pin(2026.8.3.3)上均 OK。 + +## 7. 后续待办 + +- **CN 镜像**:有 `mcpp-res` 写权限后,`gtc` 建 `mcpp-res/websocket`、传与 GLOBAL 字节一致的 + `IXWebSocket-12.0.1.tar.gz`,把三平台 url 改写为 `{ GLOBAL, CN }`(sha 不变)。 +- **TLS feature**(可选):索引里有 `compat.openssl`/`compat.mbedtls`,若要支持 wss,可加 feature 把对应 + TLS TU 与依赖编入 —— 本包刻意先做零依赖纯客户端。 +- **C++23 薄封装头**(可选):用户计划稿提到的 `websocket.hpp`(span/format/RAII 薄层)不阻塞本包,可后续单独加。 diff --git a/README.md b/README.md index 0e65ff2a..23b086a2 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Two kinds of packages live here: | Native module library (Form A) | [`mcpplibs.xpkg`](pkgs/x/xpkg.lua) · [`mcpplibs.tinyhttps`](pkgs/t/tinyhttps.lua) · [`tensorvia-cpu`](pkgs/t/tensorvia-cpu.lua) · [`ffmpeg`](pkgs/f/ffmpeg.lua) (module layer; sources compiled directly through `compat.ffmpeg`) · [`opencv`](pkgs/o/opencv.lua) (single repository: the module layer and the full OpenCV 5 source build both live in the package, and only this descriptor stays on the index side) · [`mcpplibs.grpc`](pkgs/g/grpc.lua) (gRPC 1.83.0 — the one library here that CANNOT be a compat descriptor: upstream publishes no self-contained source artifact, its tag archive carrying abseil/protobuf/re2/boringssl/zlib as empty submodule placeholders, so [grpc-m](https://github.com/mcpplibs/grpc-m)'s release tarball IS that artifact. It vendors only gRPC's own source and takes the five dependencies from this index, so a consumer that also uses protobuf links one copy rather than two) | | C-source compat (with `features`) | [`compat.cjson`](pkgs/c/compat.cjson.lua) · [`compat.zlib`](pkgs/c/compat.zlib.lua) | | C++-source compat, one depending on the other | [`compat.abseil`](pkgs/c/compat.abseil.lua) (151 TUs; a wildcard over `absl/**` trimmed by upstream's test/benchmark naming conventions) · [`compat.protobuf`](pkgs/c/compat.protobuf.lua) (the libprotobuf runtime, 79 TUs transcribed from upstream's own `src/file_lists.cmake`; declares `compat.abseil` as a dependency because protobuf's public headers include `absl/…`, and its `gzip` feature defines `HAVE_ZLIB` and pulls `compat.zlib`, while `upb` adds protobuf's 64-TU C runtime out of the same tarball) · [`compat.re2`](pkgs/c/compat.re2.lua) (22 TUs, upstream's own `RE2_SOURCES`) | +| C++-source compat, client-only with zero deps | [`compat.websocket`](pkgs/c/compat.websocket.lua) (IXWebSocket 12.0.1 — a pure RFC 6455 client compiled from upstream's `IXWEBSOCKET_SOURCES` minus the four server TUs. TLS is off (no `IXWEBSOCKET_USE_TLS`; the OpenSSL/MbedTLS/AppleSSL TUs aren't built) and zlib is off too (`IXWEBSOCKET_USE_ZLIB` unset, so the gzip codec compiles to a no-op) — no external dependency at all. Its test brings its own minimal RFC 6455 echo server on loopback sockets, so the handshake, masking, fragmentation and close are exercised offline) | | header-only (with `features`) | [`compat.eigen`](pkgs/c/compat.eigen.lua) | | Runtime loader compat (pure sources, sidestepping upstream codegen/asm) | [`compat.vulkan`](pkgs/c/compat.vulkan.lua) (the Khronos loader: `loader/generated/` is checked in, and the assembly path degrades to plain C through `UNKNOWN_FUNCTIONS_SUPPORTED`, so no CMake/Python/assembler is needed; windows deferred) · [`compat.vulkan-headers`](pkgs/c/compat.vulkan-headers.lua) | | Whole-source direct build + generated config (only where a platform lacks one) | [`compat.curl`](pkgs/c/compat.curl.lua) (win32 uses upstream's checked-in config, unix generates one) · [`compat.sdl2`](pkgs/c/compat.sdl2.lua) (win/mac use upstream's checked-in config; linux generates one and enables X11 by hand) · [`compat.c-ares`](pkgs/c/compat.c-ares.lua) (91 TUs; the release tarball already ships `ares_build.h` and a Windows config, so only `ares_config.h` is snapshotted per OS) | diff --git a/README.zh-CN.md b/README.zh-CN.md index d80c213f..111e958f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -37,6 +37,7 @@ mcpp self config --mirror CN # 切换至国内镜像,默认使用 GLOBAL 上 | 原生模块库(Form A) | [`mcpplibs.xpkg`](pkgs/x/xpkg.lua) · [`mcpplibs.tinyhttps`](pkgs/t/tinyhttps.lua) · [`tensorvia-cpu`](pkgs/t/tensorvia-cpu.lua) · [`ffmpeg`](pkgs/f/ffmpeg.lua)(模块层,源码经 `compat.ffmpeg` 直编) · [`opencv`](pkgs/o/opencv.lua)(单仓库:模块层与 OpenCV 5 全源码构建同在包内,索引侧只留本描述符) · [`mcpplibs.grpc`](pkgs/g/grpc.lua)(gRPC 1.83.0 —— 本索引里唯一**无法**做成 compat 描述符的库:上游不发布任何自包含源码产物,其 tag 归档里 abseil/protobuf/re2/boringssl/zlib 全是空 submodule 占位,因此 [grpc-m](https://github.com/mcpplibs/grpc-m) 的 release tarball 才是那个产物。它只 vendor gRPC 自己的源码,五个依赖全取自本索引,故同时直接使用 protobuf 的消费者链进去的是同一份而非两份)| | C 源码 compat(含 `features`) | [`compat.cjson`](pkgs/c/compat.cjson.lua) · [`compat.zlib`](pkgs/c/compat.zlib.lua) | | C++ 源码 compat(彼此依赖) | [`compat.abseil`](pkgs/c/compat.abseil.lua)(151 TU;对 `absl/**` 取通配后,按上游自身的 test/benchmark 命名约定裁剪) · [`compat.protobuf`](pkgs/c/compat.protobuf.lua)(libprotobuf 运行时,79 TU 逐条转录自上游 `src/file_lists.cmake`;因 protobuf 公开头文件 include 了 `absl/…`,故显式依赖 `compat.abseil`;`gzip` feature 定义 `HAVE_ZLIB` 并拉入 `compat.zlib`,`upb` feature 则从同一个 tarball 里再编出 protobuf 的 64 TU C 运行时) · [`compat.re2`](pkgs/c/compat.re2.lua)(22 TU,取自上游自身的 `RE2_SOURCES`) | +| C++ 源码 compat(纯客户端,零依赖) | [`compat.websocket`](pkgs/c/compat.websocket.lua)(IXWebSocket 12.0.1 —— 从上游 `IXWEBSOCKET_SOURCES` 剔掉 4 个 server TU 后直编的纯 RFC 6455 客户端。TLS 关闭(不定义 `IXWEBSOCKET_USE_TLS`,OpenSSL/MbedTLS/AppleSSL 三组 TU 均不编),zlib 也关闭(`IXWEBSOCKET_USE_ZLIB` 不定义,gzip codec 编译为 no-op),于是零外部依赖。其测试自带一个基于 loopback 原始 socket 的最小 RFC 6455 echo server,握手/掩码/分片/关闭全部离线实测) | | header-only(含 `features`) | [`compat.eigen`](pkgs/c/compat.eigen.lua) | | 运行时 loader compat(纯源码,绕开上游 codegen/asm) | [`compat.vulkan`](pkgs/c/compat.vulkan.lua)(Khronos loader:`loader/generated/` 已签入,汇编路径经 `UNKNOWN_FUNCTIONS_SUPPORTED` 降级为纯 C,故无需 CMake/Python/汇编器;windows 延后)· [`compat.vulkan-headers`](pkgs/c/compat.vulkan-headers.lua) | | 全源码直编 + 生成 config(仅缺口平台) | [`compat.curl`](pkgs/c/compat.curl.lua)(win32 用上游签入 config,unix 生成) · [`compat.sdl2`](pkgs/c/compat.sdl2.lua)(win/mac 用上游签入 config,linux 生成 + 手工开 X11) · [`compat.c-ares`](pkgs/c/compat.c-ares.lua)(91 TU;release tarball 已自带 `ares_build.h` 与 Windows 配置,故只需按 OS 冻结 `ares_config.h`) | diff --git a/mcpp.toml b/mcpp.toml index 7683cf23..f73b4215 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -62,6 +62,7 @@ members = [ "tests/examples/spdlog-compiled", "tests/examples/tinyhttps", "tests/examples/vulkan", + "tests/examples/websocket", "tests/examples/tray", "tests/examples/yyjson", "tests/examples/magic_enum", diff --git a/pkgs/c/compat.websocket.lua b/pkgs/c/compat.websocket.lua new file mode 100644 index 00000000..2d7c3ab0 --- /dev/null +++ b/pkgs/c/compat.websocket.lua @@ -0,0 +1,137 @@ +-- compat.websocket — IXWebSocket, a pure C++ WebSocket client (RFC 6455). +-- +-- Header-compat shape (Form B, `import_std = false`): the client TUs are +-- compiled into one lib and the public headers are exposed through +-- `include_dirs`, so a consumer writes `#include `. +-- Upstream is a flat `ixwebsocket/` directory holding both headers and sources; +-- the whole thing compiles at this index's c++23 floor even though upstream +-- targets C++11 (verified with the CI clang). +-- +-- CLIENT-ONLY. Four upstream TUs are the server side and are left out: +-- IXWebSocketServer.cpp, IXWebSocketProxyServer.cpp, IXHttpServer.cpp and +-- IXSocketServer.cpp. Nothing the client compiles references any of them +-- (checked by grepping the included TUs for their class names), so the link +-- stays clean. IXGetFreePort.cpp IS included: it is a standalone utility and +-- part of the client-side lib. +-- +-- ZERO external dependencies, all three knobs turned off: +-- +-- * No IXWEBSOCKET_USE_TLS — the TLS socket TUs (OpenSSL / MbedTLS / +-- AppleSSL) are not compiled and the public headers that reach consumers +-- never include them; IXSocketFactory.cpp only pulls the TLS headers under +-- `#ifdef IXWEBSOCKET_USE_TLS`. +-- * No IXWEBSOCKET_USE_ZLIB — IXGzipCodec.cpp and the per-message-deflate +-- codec guard every zlib call behind that macro, so they compile to +-- pass-through no-ops. Per-message-deflate negotiation is then inert on +-- the wire (clients offer no compression extension), which is exactly the +-- conservative default. +-- * No server feature — there is no compilable optional component worth +-- gating here; the server side is deliberately not offered. +-- +-- The 32 sources below transcribe upstream CMake's `IXWEBSOCKET_SOURCES` minus +-- the four server TUs above. All names are unique within the index, so the +-- flat-obj dir shared across a link (mcpp#233/#240) has no collisions. +-- +-- All `mcpp` paths are GLOBS relative to the verdir; the leading `*/` absorbs +-- the GitHub tarball's `IXWebSocket-12.0.1/` wrap layer. +package = { + spec = "1", + namespace = "compat", + name = "websocket", + description = "IXWebSocket — pure C++ WebSocket client (RFC 6455), client-only, zero deps", + licenses = {"MIT"}, + repo = "https://github.com/machinezone/IXWebSocket", + type = "package", + + xpm = { + -- CN mirror not published yet (no mcpp-res write access here); + -- plain-string url keeps lint green and lets CN users fall back to + -- upstream, per docs/cn-mirror.md. Flip to { GLOBAL, CN } once a + -- gitcode release exists — sha256 stays the same. + linux = { + ["12.0.1"] = { + url = "https://github.com/machinezone/IXWebSocket/archive/refs/tags/v12.0.1.tar.gz", + sha256 = "d23bdc91dbfe2b9ae13c322d539392d7a6b8b506560f41c90e227fa0f86a2405", + }, + }, + macosx = { + ["12.0.1"] = { + url = "https://github.com/machinezone/IXWebSocket/archive/refs/tags/v12.0.1.tar.gz", + sha256 = "d23bdc91dbfe2b9ae13c322d539392d7a6b8b506560f41c90e227fa0f86a2405", + }, + }, + windows = { + ["12.0.1"] = { + url = "https://github.com/machinezone/IXWebSocket/archive/refs/tags/v12.0.1.tar.gz", + sha256 = "d23bdc91dbfe2b9ae13c322d539392d7a6b8b506560f41c90e227fa0f86a2405", + }, + }, + }, + + mcpp = { + language = "c++23", + import_std = false, + + -- The verdir root carries `ixwebsocket/` with every header, matching + -- upstream's install layout (`/ixwebsocket/`), so consumers + -- write `#include `. + include_dirs = { "*" }, + + -- Upstream CMake `IXWEBSOCKET_SOURCES` minus the server TUs. Only the + -- first 32 of the 36 are the client; IXWebSocketServer / IXSocketServer + -- / IXHttpServer / IXWebSocketProxyServer are intentionally absent. + sources = { + "*/ixwebsocket/IXBench.cpp", + "*/ixwebsocket/IXCancellationRequest.cpp", + "*/ixwebsocket/IXConnectionState.cpp", + "*/ixwebsocket/IXDNSLookup.cpp", + "*/ixwebsocket/IXExponentialBackoff.cpp", + "*/ixwebsocket/IXGetFreePort.cpp", + "*/ixwebsocket/IXGzipCodec.cpp", + "*/ixwebsocket/IXHttp.cpp", + "*/ixwebsocket/IXHttpClient.cpp", + "*/ixwebsocket/IXNetSystem.cpp", + "*/ixwebsocket/IXSelectInterrupt.cpp", + "*/ixwebsocket/IXSelectInterruptFactory.cpp", + "*/ixwebsocket/IXSelectInterruptPipe.cpp", + "*/ixwebsocket/IXSelectInterruptEvent.cpp", + "*/ixwebsocket/IXSetThreadName.cpp", + "*/ixwebsocket/IXSocket.cpp", + "*/ixwebsocket/IXSocketConnect.cpp", + "*/ixwebsocket/IXSocketFactory.cpp", + "*/ixwebsocket/IXSocketTLSOptions.cpp", + "*/ixwebsocket/IXStrCaseCompare.cpp", + "*/ixwebsocket/IXUdpSocket.cpp", + "*/ixwebsocket/IXUrlParser.cpp", + "*/ixwebsocket/IXUuid.cpp", + "*/ixwebsocket/IXUserAgent.cpp", + "*/ixwebsocket/IXWebSocket.cpp", + "*/ixwebsocket/IXWebSocketCloseConstants.cpp", + "*/ixwebsocket/IXWebSocketHandshake.cpp", + "*/ixwebsocket/IXWebSocketHttpHeaders.cpp", + "*/ixwebsocket/IXWebSocketPerMessageDeflate.cpp", + "*/ixwebsocket/IXWebSocketPerMessageDeflateCodec.cpp", + "*/ixwebsocket/IXWebSocketPerMessageDeflateOptions.cpp", + "*/ixwebsocket/IXWebSocketTransport.cpp", + }, + + targets = { ["websocket"] = { kind = "lib" } }, + + -- ── Platform-specific ────────────────────────────────────────────── + -- Upstream: Threads::Threads on UNIX, wsock32/ws2_32/shlwapi + + -- _CRT_SECURE_NO_WARNINGS on Windows. shlwapi is only reached from the + -- excluded IXSocketOpenSSL.cpp (its PathFileExists helpers), so it is + -- dropped here; wsock32 is kept alongside ws2_32 exactly as upstream + -- lists it. + linux = { + ldflags = { "-lpthread" }, + }, + macosx = { + ldflags = { "-lpthread" }, + }, + windows = { + cxxflags = { "-D_CRT_SECURE_NO_WARNINGS" }, + ldflags = { "-lws2_32", "-lwsock32" }, + }, + }, +} diff --git a/tests/examples/websocket/mcpp.toml b/tests/examples/websocket/mcpp.toml new file mode 100644 index 00000000..f4cffcec --- /dev/null +++ b/tests/examples/websocket/mcpp.toml @@ -0,0 +1,15 @@ +# websocket test project: consumes compat.websocket and asserts client behavior +# under `mcpp test`. Part of the mcpp-index self-referential workspace — the +# workspace-root `[indices]` redirect points at this repo, so the dependency +# resolves to the checked-in recipe (pkgs/c/compat.websocket.lua). +# +# The test is entirely OFFLINE and self-contained: it runs a minimal RFC 6455 +# echo server inside the test process (raw loopback sockets) and drives the +# ix::WebSocket client against it, so it does not depend on the CI runner +# having (or not having) network access or a WS server process. +[package] +name = "websocket-tests" +version = "0.1.0" + +[dependencies.compat] +websocket = "12.0.1" diff --git a/tests/examples/websocket/tests/ws_test.cpp b/tests/examples/websocket/tests/ws_test.cpp new file mode 100644 index 00000000..53be8a66 --- /dev/null +++ b/tests/examples/websocket/tests/ws_test.cpp @@ -0,0 +1,604 @@ +// ws_test.cpp — self-contained WebSocket client test for compat.websocket. +// +// IXWebSocket is a pure client here (the descriptor builds the client TUs +// only), so this test brings its OWN minimal RFC 6455 echo server, built on +// raw sockets, and drives the ix::WebSocket client against 127.0.0.1:0. That +// exercises the real wire behaviors the client must implement — the HTTP +// upgrade handshake (the client validates Sec-WebSocket-Accept), client-side +// masking, fragmentation (the server replies with a fragmented message and the +// client must reassemble it), ping/pong in both directions, and the closing +// handshake. No network access and no server process are required: everything +// stays inside one process on the loopback interface. +// +// The server is intentionally independent of the library under test: it +// computes Sec-WebSocket-Accept with its own SHA-1 + base64 and implements the +// framing itself, so a client bug in any of those areas fails the assertions +// below rather than being hidden by shared code. + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +int g_failures = 0; + +void check(bool ok, const std::string& what) +{ + if (ok) + { + std::cout << " ok: " << what << "\n"; + } + else + { + std::cout << " FAIL: " << what << "\n"; + ++g_failures; + } +} + +// ── SHA-1 and base64 (for Sec-WebSocket-Accept) ──────────────────────────── +// Compact reference implementations, independent of the library under test. + +std::string base64_encode(const std::string& in) +{ + static const char tbl[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + unsigned val = 0; + int valb = -6; + for (unsigned char c : in) + { + val = (val << 8) | c; + valb += 8; + while (valb >= 0) + { + out.push_back(tbl[(val >> valb) & 0x3F]); + valb -= 6; + } + } + if (valb > -6) out.push_back(tbl[((val << 8) >> (valb + 8)) & 0x3F]); + while (out.size() % 4) out.push_back('='); + return out; +} + +std::string sha1_binary(const std::string& msg) +{ + uint32_t h0 = 0x67452301, h1 = 0xEFCDAB89, h2 = 0x98BADCFE, h3 = 0x10325476, + h4 = 0xC3D2E1F0; + uint64_t bitlen = static_cast(msg.size()) * 8; + std::string m = msg; + m.push_back(static_cast(0x80)); + while (m.size() % 64 != 56) m.push_back('\0'); + for (int i = 7; i >= 0; --i) + m.push_back(static_cast((bitlen >> (i * 8)) & 0xFF)); + + for (size_t i = 0; i < m.size(); i += 64) + { + uint32_t w[80]; + for (int j = 0; j < 16; ++j) + w[j] = (static_cast(static_cast(m[i + j * 4])) << 24) | + (static_cast(static_cast(m[i + j * 4 + 1])) << 16) | + (static_cast(static_cast(m[i + j * 4 + 2])) << 8) | + (static_cast(static_cast(m[i + j * 4 + 3]))); + for (int j = 16; j < 80; ++j) + { + uint32_t x = w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16]; + w[j] = (x << 1) | (x >> 31); + } + uint32_t a = h0, b = h1, c = h2, d = h3, e = h4; + for (int j = 0; j < 80; ++j) + { + uint32_t f, k; + if (j < 20) + { + f = (b & c) | ((~b) & d); + k = 0x5A827999; + } + else if (j < 40) + { + f = b ^ c ^ d; + k = 0x6ED9EBA1; + } + else if (j < 60) + { + f = (b & c) | (b & d) | (c & d); + k = 0x8F1BBCDC; + } + else + { + f = b ^ c ^ d; + k = 0xCA62C1D6; + } + uint32_t tmp = ((a << 5) | (a >> 27)) + f + e + k + w[j]; + e = d; + d = c; + c = (b << 30) | (b >> 2); + b = a; + a = tmp; + } + h0 += a; + h1 += b; + h2 += c; + h3 += d; + h4 += e; + } + std::string out(20, '\0'); + const uint32_t hs[5] = {h0, h1, h2, h3, h4}; + for (int i = 0; i < 5; ++i) + for (int j = 0; j < 4; ++j) + out[i * 4 + j] = static_cast((hs[i] >> ((3 - j) * 8)) & 0xFF); + return out; +} + +// ── Platform socket abstraction ──────────────────────────────────────────── + +#ifdef _WIN32 +#include +#include +using SockType = SOCKET; +constexpr SockType kInvalidSocket = INVALID_SOCKET; +using AddrLenType = int; +#else +#include +#include +#include +#include +#include +using SockType = int; +constexpr SockType kInvalidSocket = -1; +using AddrLenType = socklen_t; +#endif + +void init_sockets() +{ +#ifdef _WIN32 + WSADATA wsa; + WSAStartup(MAKEWORD(2, 2), &wsa); +#endif +} + +void close_socket(SockType s) +{ +#ifdef _WIN32 + closesocket(s); +#else + ::close(s); +#endif +} + +// Safety net: any read that stalls this long makes the server thread return so +// the test fails cleanly instead of hanging the process (and, on CI, a runner). +void set_recv_timeout(SockType s, int ms) +{ +#ifdef _WIN32 + DWORD t = static_cast(ms); + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&t), sizeof(t)); +#else + timeval tv{}; + tv.tv_sec = ms / 1000; + tv.tv_usec = (ms % 1000) * 1000; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); +#endif +} + +// ── Minimal RFC 6455 echo server ─────────────────────────────────────────── + +class EchoServer +{ +public: + ~EchoServer() + { + if (client != kInvalidSocket) close_socket(client); + if (listener != kInvalidSocket) close_socket(listener); + if (thread.joinable()) thread.join(); + } + + bool start() + { + init_sockets(); + listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener == kInvalidSocket) return false; + int one = 1; +#ifdef _WIN32 + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, + reinterpret_cast(&one), sizeof(one)); +#else + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); +#endif + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; // ephemeral + if (bind(listener, reinterpret_cast(&addr), sizeof(addr)) != 0) return false; + AddrLenType alen = sizeof(addr); + getsockname(listener, reinterpret_cast(&addr), &alen); + port = ntohs(addr.sin_port); + if (listen(listener, 1) != 0) return false; + thread = std::thread([this] { run(); }); + return true; + } + + int port = 0; + std::atomic server_saw_masked{false}; + std::atomic server_saw_pong{false}; + std::atomic server_saw_close{false}; + +private: + SockType listener = kInvalidSocket; + SockType client = kInvalidSocket; + std::thread thread; + + // Receive exactly n bytes; false on timeout/error/disconnect. + bool recv_n(char* buf, size_t n) + { + size_t got = 0; + while (got < n) + { + int r = recv(client, buf + got, static_cast(n - got), 0); + if (r <= 0) return false; + got += static_cast(r); + } + return true; + } + + std::string lower(std::string s) + { + for (char& c : s) + if (c >= 'A' && c <= 'Z') c = static_cast(c + ('a' - 'A')); + return s; + } + + bool handshake() + { + std::string req; + char tmp[1024]; + while (req.find("\r\n\r\n") == std::string::npos) + { + int r = recv(client, tmp, sizeof(tmp), 0); + if (r <= 0) return false; + req.append(tmp, static_cast(r)); + if (req.size() > 65536) return false; + } + std::string key; + std::string rest = req; + for (;;) + { + size_t eol = rest.find("\r\n"); + if (eol == std::string::npos) break; + std::string line = rest.substr(0, eol); + rest = rest.substr(eol + 2); + size_t colon = line.find(':'); + if (colon == std::string::npos) continue; + std::string name = lower(line.substr(0, colon)); + std::string value = line.substr(colon + 1); + size_t b = value.find_first_not_of(" \t"); + size_t e = value.find_last_not_of(" \t"); + value = (b == std::string::npos) ? std::string() : value.substr(b, e - b + 1); + if (name == "sec-websocket-key") key = value; + } + if (key.empty()) return false; + + const std::string guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + std::string accept = base64_encode(sha1_binary(key + guid)); + + std::string resp = "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: " + accept + "\r\n" + "\r\n"; + size_t off = 0; + while (off < resp.size()) + { + int w = send(client, resp.data() + off, static_cast(resp.size() - off), 0); + if (w <= 0) return false; + off += static_cast(w); + } + return true; + } + + bool send_frame(uint8_t opcode, const std::string& payload, bool fin = true) + { + // Server->client frames are NOT masked (RFC 6455 §5.1). + std::string frame; + frame.push_back(static_cast((fin ? 0x80 : 0x00) | (opcode & 0x0F))); + size_t n = payload.size(); + if (n < 126) + { + frame.push_back(static_cast(n)); + } + else if (n <= 0xFFFF) + { + frame.push_back(static_cast(126)); + frame.push_back(static_cast((n >> 8) & 0xFF)); + frame.push_back(static_cast(n & 0xFF)); + } + else + { + frame.push_back(static_cast(127)); + for (int i = 7; i >= 0; --i) + frame.push_back(static_cast((n >> (i * 8)) & 0xFF)); + } + frame += payload; + size_t off = 0; + while (off < frame.size()) + { + int w = send(client, frame.data() + off, static_cast(frame.size() - off), 0); + if (w <= 0) return false; + off += static_cast(w); + } + return true; + } + + bool recv_frame(uint8_t& opcode, std::string& payload) + { + char hdr[2]; + if (!recv_n(hdr, 2)) return false; + bool fin = (hdr[0] & 0x80) != 0; + (void) fin; + opcode = static_cast(hdr[0] & 0x0F); + bool masked = (hdr[1] & 0x80) != 0; + if (masked) server_saw_masked = true; + size_t len = static_cast(hdr[1] & 0x7F); + if (len == 126) + { + char ext[2]; + if (!recv_n(ext, 2)) return false; + len = (static_cast(static_cast(ext[0])) << 8) | + static_cast(static_cast(ext[1])); + } + else if (len == 127) + { + char ext[8]; + if (!recv_n(ext, 8)) return false; + len = 0; + for (int i = 0; i < 8; ++i) + len = (len << 8) | static_cast(static_cast(ext[i])); + } + char key[4] = {0, 0, 0, 0}; + if (masked && !recv_n(key, 4)) return false; + payload.resize(len); + if (len && !recv_n(&payload[0], len)) return false; + if (masked) + for (size_t i = 0; i < len; ++i) + payload[i] = static_cast(payload[i] ^ key[i % 4]); + return true; + } + + void run() + { + sockaddr_in peer{}; + AddrLenType plen = sizeof(peer); + client = accept(listener, reinterpret_cast(&peer), &plen); + if (client == kInvalidSocket) return; + set_recv_timeout(client, 10000); + if (!handshake()) return; + + for (;;) + { + uint8_t opcode = 0; + std::string payload; + if (!recv_frame(opcode, payload)) return; + + switch (opcode) + { + case 0x9: // ping -> pong + if (!send_frame(0xA, payload)) return; + break; + case 0xA: // pong (answer to our ping) + server_saw_pong = true; + break; + case 0x8: // close -> echo close code, then shut down + { + server_saw_close = true; + std::string close_payload = payload; + if (close_payload.size() < 2) close_payload = std::string("\x03\xe8", 2); // 1000 + if (!send_frame(0x8, close_payload)) return; + return; + } + case 0x1: // text + case 0x2: // binary + { + if (payload == "FRAG") + { + // Reply with a fragmented text message. + if (!send_frame(0x1, "fragA-", false)) return; + if (!send_frame(0x0, "fragB-", false)) return; + if (!send_frame(0x0, "fragC!", true)) return; + } + else if (payload == "PINGME") + { + // Ask the client to answer a ping with a pong. + if (!send_frame(0x9, "server-ping", true)) return; + } + else + { + if (!send_frame(opcode, payload, true)) return; + } + break; + } + default: + // continuation frames and anything else: ignore. + break; + } + } + } +}; + +// ── Client-side message collector ────────────────────────────────────────── + +struct RecvMsg +{ + ix::WebSocketMessageType type; + std::string str; + bool binary; +}; + +class Collector +{ +public: + void add(const ix::WebSocketMessagePtr& msg) + { + std::lock_guard lk(m_); + msgs_.push_back(RecvMsg{msg->type, msg->str, msg->binary}); + cv_.notify_all(); + } + + // Wait up to timeout_ms for a predicate over the collected messages. + template + bool wait_for(Pred pred, int timeout_ms) + { + std::unique_lock lk(m_); + return cv_.wait_for(lk, std::chrono::milliseconds(timeout_ms), [&] { return pred(msgs_); }); + } + + bool has_type(ix::WebSocketMessageType t) const + { + std::lock_guard lk(m_); + for (const auto& m : msgs_) + if (m.type == t) return true; + return false; + } + +private: + mutable std::mutex m_; + std::condition_variable cv_; + std::vector msgs_; +}; + +} // namespace + +int main() +{ + // Unbuffered: `mcpp test` runs this piped, and a hang without visible + // progress is indistinguishable from a slow pass otherwise. + std::cout << std::unitbuf; + + EchoServer server; + if (!server.start()) + { + std::cout << "FAIL: could not start echo server\n"; + return 1; + } + std::cout << "server listening on 127.0.0.1:" << server.port << "\n"; + + bool netOk = ix::initNetSystem(); + check(netOk, "ix::initNetSystem()"); + + Collector collector; + ix::WebSocket ws; + ws.setUrl("ws://127.0.0.1:" + std::to_string(server.port) + "/"); + ws.setOnMessageCallback([&](const ix::WebSocketMessagePtr& msg) { collector.add(msg); }); + ws.start(); + + // 1. Handshake: the client must validate our Sec-WebSocket-Accept and open. + check(collector.wait_for( + [](const std::vector& v) { + for (const auto& m : v) + if (m.type == ix::WebSocketMessageType::Open) return true; + return false; + }, + 5000), + "client opened after HTTP upgrade handshake"); + + // 2. Text echo — by the time the server echoes, it has seen the client's + // masked frames (RFC 6455 §5.1 requires client->server masking). + ws.sendText("hello, ws!"); + check(collector.wait_for( + [](const std::vector& v) { + for (const auto& m : v) + if (m.type == ix::WebSocketMessageType::Message && m.str == "hello, ws!" && + !m.binary) + return true; + return false; + }, + 5000), + "text message echoed back"); + check(server.server_saw_masked.load(), "client frames arrive masked"); + + // 3. Binary echo (arbitrary bytes, not valid UTF-8). + std::string bin("\x00\x01\x02\xfe\xff\x80hello", 9); + ws.sendBinary(bin); + check(collector.wait_for( + [&](const std::vector& v) { + for (const auto& m : v) + if (m.type == ix::WebSocketMessageType::Message && m.str == bin && m.binary) + return true; + return false; + }, + 5000), + "binary message echoed back byte-for-byte"); + + // 4. Client ping -> server pong -> client surfaces the Pong message. + ws.ping("ping-payload"); + check(collector.wait_for( + [](const std::vector& v) { + for (const auto& m : v) + if (m.type == ix::WebSocketMessageType::Pong && m.str == "ping-payload") + return true; + return false; + }, + 5000), + "ping/pong round-trip (client-initiated)"); + + // 5. Server ping -> client auto-pong (enablePong default is on). + ws.sendText("PINGME"); + { + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!server.server_saw_pong.load() && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + check(server.server_saw_pong.load(), "client auto-answered a server ping with a pong"); + + // 6. Fragmentation: server replies with a fragmented text message, the + // client must reassemble it into one Message (Fragment frames precede it). + ws.sendText("FRAG"); + check(collector.wait_for( + [&](const std::vector& v) { + bool sawFragment = false; + for (const auto& m : v) + { + if (m.type == ix::WebSocketMessageType::Fragment) sawFragment = true; + if (m.type == ix::WebSocketMessageType::Message && m.str == "fragA-fragB-fragC!") + return true; + } + (void) sawFragment; + return false; + }, + 5000), + "fragmented server message reassembled by the client"); + check(collector.has_type(ix::WebSocketMessageType::Fragment), + "fragment frames surfaced before the reassembled message"); + + // 7. Close handshake: stop() initiates the close (via its internal + // close()), waits for the server's close reply and for the client thread + // to exit, and sets `_stop` so the run loop cannot fall into the + // automatic reconnection that a CLOSED state would otherwise trigger + // (IXWebSocket enables automatic reconnection by default). The Close + // message is delivered to the collector before stop() returns. + ws.stop(); + check(collector.has_type(ix::WebSocketMessageType::Close), + "closing handshake completed (client saw Close)"); + check(server.server_saw_close.load(), "server received the client's close frame"); + + ix::uninitNetSystem(); + + if (g_failures == 0) + { + std::cout << "ALL WEBSOCKET ASSERTIONS PASSED\n"; + return 0; + } + std::cout << g_failures << " ASSERTION(S) FAILED\n"; + return 1; +}