From d06f75f3f01d37c588c267e5cdcc2c5e50a36aed Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:34:58 +0530 Subject: [PATCH 1/6] feat(i18n)!: drop the Chinese column, keep the plumbing TK_LANGUAGE_CHINESE is removed; unknown values are ignored, so older callers get English output. --- AGENTS.md | 1 + include/tetherkit/capi/tetherkit_c.h | 42 +- include/tetherkit/common/i18n.h | 146 ++-- include/tetherkit/common/messages.def | 1053 +++++++++---------------- src/capi/environment.cc | 12 +- src/common/i18n.cc | 92 ++- tests/language_guard.h | 19 +- tests/test_common_i18n.cc | 178 ++--- tests/test_core_bridge.cc | 8 +- tests/test_net_link.cc | 15 +- tests/test_rndis_packet_codec.cc | 8 +- tests/test_rndis_state_machine.cc | 23 +- 12 files changed, 680 insertions(+), 917 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c7cfc12..92c392e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -269,6 +269,7 @@ Swift 的 C++ 互操作吞不下,所以 C ABI 这一层不可省。 | 51 | `fix(ci): drop the dead bench args dispatch input` | ✅ | Third-round residual, addressed rather than left moot: the Run step carried the args dispatch input through an unquoted BENCH_ARGS (intentional word-splitting), but the input itself was dead: benchmarks/ contains no argv or argc use at all (grep-verified before the comment that says so was written), so no dispatch text could ever change the run. workflow_dispatch now declares no inputs, the Run step invokes tetherkit_bench bare behind pipefail+tee, and the inputs.args env-passage comment went with the plumbing it described | | 54 | `fix(ci): force arm64 target architecture in codeql build` | ✅ | Diagnosed CodeQL run 42513807606 (2026-11-08): job failed with linker error "Undefined symbols for architecture x86_64" and "found architecture 'arm64', required architecture 'x86_64'" against Homebrew's /opt/homebrew/Cellar/libusb/1.0.30/lib/libusb-1.0.dylib. CodeQL's macOS tracer forces traced child processes onto the x86_64 slice under Rosetta; without an explicit target architecture, AppleClang emits x86_64 objects that cannot link against arm64-only Homebrew dependencies. `codeql.yml` pins `CMAKE_OSX_ARCHITECTURES=arm64` so clang cross-emits arm64 regardless of driver process architecture; `arch -arm64` cannot be used instead because child builds forced native bypass CodeQL tracing. Hardened `codeql.yml` with a `pull_request` trigger scoped to `.github/**` so workflow edits self-verify. Verified green by two follow-up runs on the branch — 42578919426 (pull_request event) and 42578945162 (workflow_dispatch; 11m59s C++-analysis job span, 12m5s run wall, Build and analyze clean against arm64 Homebrew libusb) — sealing the failure-plus-verification pairing exactly like rows 39/41/42/47. | | 55 | `fix(ci): narrow codeql pr trigger, event-scope concurrency` | ✅ | Review round on row 54, five findings, four applied in codeql.yml plus this row's amendment: (1, consensus) pull_request paths narrowed `.github/**` -> `.github/workflows/**` - dependabot.yml and .github/scripts/ edits never reach the analyzer, while Dependabot's weekly grouped action-bump PR still matches by construction (it edits workflow files) and the header comment now states that ride-along as deliberate; (2, sharpest catch) the header's "No concurrency block: canceling would throw away the whole analysis" sentence predated the PR trigger and no longer held - a superseded PR commit's SARIF is simply replaced by the newer run - so an event-scoped concurrency group mirroring tests.yml landed (group `${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}`, cancel-in-progress: true) with a comment explaining why cancellation is safe within one event and why event_name in the key keeps a push to main from canceling the Monday cron; (3) row 54 amended (above) to cite verification runs 42578919426 and 42578945162, sealing the failure-plus-verification pairing the ledger's audit value depends on; (4, nit) the Build step comment now carries the hard-coded-on-purpose guard - under the tracer `uname -m` reports x86_64, so a `-DCMAKE_OSX_ARCHITECTURES=$(uname -m)` "simplification" would silently reintroduce the bug (the deliberate-choices-need-a-magnitude rule from pitfall 16); (5, INFO) the first-ever green analysis surfaced one pre-existing `cpp/use-after-free` at vendored third_party/doctest/doctest.h:3710 - resolved as a documented decline rather than config: `paths-ignore` is verifiably inert for C/C++ manual builds (codeql team statement in github/codeql-action#3890; GitHub docs confine paths filtering to build-mode none; the traced run would warn "path-based filtering not supported"), and SARIF post-filtering via advanced-security/filter-sarif means a new third-party action for exactly one benign vendored alert - the workflow header records the accepted baseline instead; the fork-PR-403 finding was retracted by the reviewers against current GitHub docs (SARIF upload on pull_request events is permitted under read-only fork tokens). Two disjoint antigravity/gemini-3.8-flash delegates applied the codeql.yml and AGENTS.md edits byte-exact within disjoint file scopes. Verification: the PR-triggered analysis run 42583242299 is green on 75634ad, and the new group's cancel-in-progress proved itself on this very push - the in-flight run 42582860934 for the superseded commit 84d8151 was canceled and replaced by the 75634ad run. | +| 56 | `feat(i18n)!: drop the Chinese column, keep the single-language wiring` (this branch) | ✅ | Maintainer decision: English-only, but preserving the multi-language plumbing so adding a language stays a bounded five-touch change (checklist now lives at the top of messages.def). messages.def went 843 -> 529 lines via a string-aware perl splice (TETHERKIT_MESSAGE(id, zh, en) -> (id, en)), 310 records; guards on kept column: no Han, no full-width punctuation. i18n.h/i18n.cci rewritten in English: Language enum reduced to kEnglish=0, single kMessageTable, TextIn kept as the designed per-language dispatch point, ParseLanguage no longer accepts zh/Chinese (returns false, CLI rejects with new English message), LanguageFromLocaleString parked at "always English" with the prefix rule to return there, ContextSeparator fixed to ": ". C ABI signature-stable: TK_LANGUAGE_CHINESE removed; ToLanguage returns nullopt for unknown values so a caller still sending the old Chinese value 1 is ignored and stays in English; helper XPC setLanguage method and protocolRevision 3 untouched (still applied, inert). Four ScopedLanguage guard sites flipped kChinese->kEnglish with English expectations (bridge stats "queue depth", CONDIS reject "connection-oriented", keepalive fatal "keep-alive", MalformedReason "none"); test_net_link's sysctl-why bilingual loop collapsed to the single table. test_common_i18n.cc rewritten: per-string placeholder well-formedness, no mixed indexing, contiguous indices 0..n-1; the cross-language parity checks were retired and are documented to come back when a second language lands. CLI help no longer advertises zh. Verification: cmake -Werror build clean, ctest 23/23 suites pass, smokes green (--version; --help shows the reworded --lang lines; --lang zh --list errors in English; --lang en --list and LC_ALL=zh_CN --list render English). GUI side deliberately untouched this phase, so `swift test` still exercises bilingual Swift tables and must be green throughout; the Swift strip is the next commit. Splice/validation scripts live in the session temp dir (not in the repo). Untracked docs/icon_resources/ and draft_icons/ predate this work and were left alone. | ### 当前状态 diff --git a/include/tetherkit/capi/tetherkit_c.h b/include/tetherkit/capi/tetherkit_c.h index a45c18c..5ea251f 100644 --- a/include/tetherkit/capi/tetherkit_c.h +++ b/include/tetherkit/capi/tetherkit_c.h @@ -100,34 +100,48 @@ typedef struct tk_error { } tk_error_t; // ============================================================================= -// 界面语言 +// Interface language // -// 库自己产生的全部文字 —— 错误消息、日志行、事件文本 —— 都按这里设定的语言 -// 渲染。宿主(GUI)负责把用户的选择推进来;命令行自己按环境变量与 --lang 决定。 +// Every string the library produces on its own -- error messages, log lines, +// event text -- renders in the language set here. The host (the GUI) pushes +// the user's selection in; the command line decides from environment +// variables and --lang on its own. // -// **进程级的单一状态**,不是每个会话一份:日志与错误从多个线程产生,做成 -// 线程局部只会让同一次会话的输出出现两种语言。 +// One process-wide state, not one per session: logs and errors come from +// multiple threads, and per-thread state would split a single session's +// output across languages. // ============================================================================= +// Language tags the shared library understands. English is the only language +// shipped today. When a language is added (see the checklist at the top of +// messages.def), this enum gains a value; old callers compiled against the +// narrower enum keep working because unknown values are ignored at set +// and degrade to English output by design. typedef enum tk_language { TK_LANGUAGE_ENGLISH = 0, - TK_LANGUAGE_CHINESE = 1, } tk_language_t; -/// 设置界面语言。任意线程可调,随时可调。 +/// Sets the interface language. Any thread, any time. /// -/// 立即生效,但**已经排队的日志与事件不会重新渲染** —— 它们在产生的那一刻就 -/// 已经是字符串了。GUI 应在启动时尽早调用,之后只在用户切换语言时再调。 -/// `language` 越界时忽略本次调用。 +/// Takes effect immediately for new strings, but log lines and events that +/// are already queued do not re-render -- they were materialized as strings +/// when they were produced. The GUI should call this once early at startup +/// and then only when the user switches the language. +/// +/// A `language` value outside tk_language_t is ignored by design, so a +/// caller built against an older (or future) enumeration keeps working and +/// simply stays in English. TK_API void tk_set_language(int32_t language); -/// 读当前界面语言(tk_language_t)。 +/// Reads the current interface language as a tk_language_t value. TK_API int32_t tk_get_language(void); -/// 按环境变量推断语言,依次看 TETHERKIT_LANG、LC_ALL、LC_MESSAGES、LANG。 +/// Infers the language from environment variables, checking TETHERKIT_LANG, +/// LC_ALL, LC_MESSAGES, and LANG in that order. /// -/// **只做推断,不改变当前设置。** GUI 通常用不上它(macOS 的语言偏好该问 -/// NSLocale),它是给命令行宿主和第三方绑定准备的。 +/// Infers only; does not change the current setting. The GUI normally does +/// not need it (macOS language preference belongs to NSLocale); it exists +/// for command-line hosts and third-party bindings. TK_API int32_t tk_detect_system_language(void); // ============================================================================= diff --git a/include/tetherkit/common/i18n.h b/include/tetherkit/common/i18n.h index 5d2cc49..f3373c1 100644 --- a/include/tetherkit/common/i18n.h +++ b/include/tetherkit/common/i18n.h @@ -1,34 +1,43 @@ -// 面向用户的文案与运行期语言切换。 +// User-facing strings and runtime language selection. // -// ★ 为什么不用 gettext ★ +// Why not gettext: // -// gettext 要引入 libintl、要在构建期跑 msgfmt 生成 .mo、还要在运行期按路径 -// 查目录。本项目只需要两种语言、几百条固定文案,且要能同时服务命令行、 -// 共享库与 GUI(GUI 通过 C ABI 切换语言)。用一张编译进二进制的表最省事, -// 也不会出现「装到别的机器上找不到 .mo 于是全变英文」这种运行期故障。 +// gettext would pull in libintl, require msgfmt at build time, and look up +// .mo files by path at runtime. This project needs a fixed set of strings in +// C++ covering the CLI, the shared library, and the GUI (the GUI switches the +// language through the C ABI). One table compiled into the binary is the +// cheapest correct option, and it cannot fail at runtime the way a missing +// .mo file can. // -// ★ 文案表怎么组织 ★ +// How the table is organized: // -// 全部文案集中在 messages.def 里,一条一行: +// All strings live in messages.def, one row each: // -// TETHERKIT_MESSAGE(kCliUnknownOption, "未知选项 \"{}\"", "unknown option \"{}\"") +// TETHERKIT_MESSAGE(kCliUnknownOption, "unknown option \"{}\"") // -// 这个文件被 X-macro 展开三次,分别生成 `Msg` 枚举、中文表与英文表。三者 -// 由同一份源头生成,**结构上不可能出现某种语言漏了一条**。剩下唯一会出错的 -// 地方是两种语言的占位符对不上,那由 tests/test_common_i18n.cc 逐条核对。 +// The file is expanded by X-macros, producing the `Msg` enum (here) and the +// message table (in i18n.cc) from the same source, so a missing entry is +// structurally impossible. The one remaining error class is a placeholder +// mismatch inside a string; tests/test_common_i18n.cc checks every string. // -// ★ 怎么用 ★ +// Usage: // -// * 普通文案:`Tr(Msg::kCliUnknownOption, argument)` —— 参数与 std::format -// 完全一致,返回 std::string。 -// * 日志:用 TETHERKIT_INFO_TR 等宏(见 logging.h),它们保留了「级别没开就 -// 不求值」的惰性。 -// * 错误:`Error::Generic(Tr(Msg::kFoo, x))`。 +// * Plain strings: Tr(Msg::kCliUnknownOption, argument) -- the arguments are +// exactly std::format's; the result is a std::string. +// * Logging: the TETHERKIT_INFO_TR family (logging.h) keeps the +// do-not-evaluate-when-disabled laziness. +// * Errors: Error::Generic(Tr(Msg::kFoo, x)). // -// ★ 热路径禁用 ★ +// Never on hot paths: // -// Tr() 会做一次 vformat 并返回 std::string,必然分配堆内存。和 error.h 一样, -// **数据热路径绝不允许调用**。 +// Tr() runs vformat and returns std::string, so it allocates. Like error.h, +// it must not be called in data-path hot code. +// +// Adding a language later: +// +// The pipeline below is language-count agnostic: an enum, one lookup, one +// parser, one env detector, and three mirrors (C ABI + Swift). The exact +// five-touch checklist lives at the top of messages.def. #pragma once #include @@ -38,81 +47,108 @@ namespace tetherkit { -/// 目前支持的界面语言。 +/// The interface languages the library can serve. /// -/// 刻意不做成开放式的语言标签字符串:多一种语言就要多一列文案,编译期能查出 -/// 「漏了一条」比运行期回落到某个默认语言更有价值。 +/// Deliberately a closed enum, not an open language-tag string: one language +/// costs one column of strings in messages.def, and a compile-time "missing +/// column" failure beats a runtime fallback. +/// +/// English is the only language shipped today. When a language is added, this +/// enum gains a value and TextIn() gains a table (see messages.def checklist). enum class Language : std::uint8_t { kEnglish = 0, - kChinese = 1, }; -/// 文案标识。取值由 messages.def 生成。 +/// Message identifiers. Values are generated from messages.def. enum class Msg : std::uint16_t { -#define TETHERKIT_MESSAGE(id, zh, en) id, +#define TETHERKIT_MESSAGE(id, en) id, #include "tetherkit/common/messages.def" // NOLINT(bugprone-suspicious-include) #undef TETHERKIT_MESSAGE - /// 哨兵:文案条数。仅供表长断言与测试遍历使用,不是一条真实文案。 + /// Sentinel: the number of messages. For table-length assertions and test + /// loops only, not a real message. kMessageCount, }; -/// 设置当前语言。线程安全,任何时候都可以调用。 +/// Sets the current language. Thread-safe, callable at any time. +/// +/// Process-wide single state: logs and error strings come from multiple +/// threads, and per-thread state would split one session's output across +/// languages. /// -/// 进程级的单一状态:日志与错误串会从多个线程产生,做成线程局部只会让同一次 -/// 会话的输出出现两种语言。 +/// Wiring note: with one shipped language every value resolves to the same +/// table, but the API stays exactly as it will be when a second language +/// lands, so callers do not change again. void SetLanguage(Language language) noexcept; [[nodiscard]] Language GetLanguage() noexcept; -/// 按环境变量推断语言,依次看 `TETHERKIT_LANG`、`LC_ALL`、`LC_MESSAGES`、`LANG`。 +/// Infers the language from environment variables, checking `TETHERKIT_LANG`, +/// `LC_ALL`, `LC_MESSAGES`, and `LANG` in that order. /// -/// 取第一个非空的值,以 `zh` 开头(大小写不敏感)时判定为中文,其余一律英文; -/// 四个都没设时也是英文 —— 这与 POSIX 的 "C" 缺省区域一致。 +/// The first non-empty value wins (POSIX's LC_ALL > LC_MESSAGES > LANG order), +/// so stray later values cannot influence the result. /// -/// ⚠️ `sudo` 是否把 `LANG` 透传给命令取决于 sudoers 的 `env_keep`,因此命令行 -/// 还提供 `--lang` 显式指定,不要指望环境变量在 sudo 下一定还在。 +/// With one shipped language this always resolves to English; the chain stays +/// in place so a second language needs no new machinery, only the prefix rule +/// in LanguageFromLocaleString(). +/// +/// `sudo` may not pass any of these through depending on `env_keep`, which is +/// why the CLI also has `--lang`. [[nodiscard]] Language DetectLanguageFromEnvironment() noexcept; -/// 把一个区域设置串(`"zh_CN.UTF-8"`、`"en_US"`、`"C"`……)判定成语言。 +/// Maps a locale string (`"zh_CN.UTF-8"`, `"en_US"`, `"C"`, ...) to a language. /// -/// 判据只有一条:以 `zh` 开头(大小写不敏感)算中文,其余一概英文。空串同样 -/// 算英文。DetectLanguageFromEnvironment 就是拿环境变量的值来喂它 —— -/// 拆成独立函数是为了能不碰环境变量就把这条判据测干净。 +/// Today every input resolves to English because English is the only shipped +/// language. When a language is added, its prefix rule lands here (case +/// insensitive, prefix match). Kept as its own function so the rule can be +/// tested without touching the environment. [[nodiscard]] Language LanguageFromLocaleString(std::string_view locale) noexcept; -/// 解析 `--lang` 的取值。接受 `zh` / `zh-Hans` / `zh_CN` / `chinese`、 -/// `en` / `en-US` / `english`、以及 `auto`(按环境推断)。 +/// Parses an `--lang` value. Accepts `en` / `en-US` / `english`, plus `auto` +/// and `system`, which defer to DetectLanguageFromEnvironment(). +/// +/// Spelling rules for removed or future languages deliberately return false, +/// so a language cannot be "half" supported: either it parses and has a table, +/// or it is an unknown value. /// -/// @return 解析成功返回 true 并写入 `out_language`;无法识别时返回 false 且 -/// 不改动 `out_language`。 +/// @return true and writes `out_language` on success; false and leaves +/// `out_language` untouched when unrecognized. [[nodiscard]] bool ParseLanguage(std::string_view text, Language* out_language) noexcept; -/// 语言的短标签(`"zh"` / `"en"`),用于回显与日志。 +/// Short tag for a language (`"en"`), used for echoes and logs. [[nodiscard]] std::string_view LanguageTag(Language language) noexcept; -/// 取某条文案在**当前语言**下的原文(未做参数替换)。 +/// Returns the raw (unsubstituted) string for a message in the current +/// language. /// -/// `id` 越界时返回空串而不是崩溃 —— 文案查表失败绝不该拖垮驱动。 +/// An out-of-range `id` returns an empty string rather than crashing -- a +/// message-table failure must never take the driver down. [[nodiscard]] std::string_view Text(Msg id) noexcept; -/// 取某条文案在**指定语言**下的原文。测试用它逐语言核对占位符。 +/// Returns the raw string for a message in the given language. +/// +/// The language dispatch is the one function a new language changes (one more +/// table). Tests use it to address specific tables directly. [[nodiscard]] std::string_view TextIn(Language language, Msg id) noexcept; namespace detail { -/// Tr() 的实现。做成非模板函数,避免每个调用点都实例化一份 vformat。 +/// Tr()'s implementation. A non-template function, so each call site does not +/// instantiate its own vformat copy. /// -/// 内部吞掉 std::format_error:文案表里的占位符与调用点对不上时(只可能是 -/// 翻译写错了),返回未替换的原文,而不是让一次日志把进程掀翻。 +/// Swallows std::format_error internally: when a string's placeholders do not +/// match its call site (only ever a string mistake), the result is the +/// un-substituted original text, not a process-killing throw. [[nodiscard]] std::string FormatMessage(Msg id, std::format_args args) noexcept; } // namespace detail -/// 按当前语言取文案并替换参数。参数语义与 `std::format` 完全一致。 +/// Looks up a message in the current language and substitutes arguments. +/// Argument semantics are std::format's exactly. /// -/// 与 `std::format` 的唯一区别是格式串来自运行期查表,因此**编译器无法**检查 -/// 占位符与参数是否匹配。这一层保障由 tests/test_common_i18n.cc 承担:它会核对 -/// 每条文案两种语言的占位符集合一致,并用真实参数试跑一遍。 +/// The one difference from `std::format` is that the format string comes from +/// a runtime table, so the compiler cannot check placeholder/argument +/// agreement. tests/test_common_i18n.cc covers that gap. template [[nodiscard]] std::string Tr(Msg id, const Args&... args) { return detail::FormatMessage(id, std::make_format_args(args...)); diff --git a/include/tetherkit/common/messages.def b/include/tetherkit/common/messages.def index 1ed8be0..72bd6e6 100644 --- a/include/tetherkit/common/messages.def +++ b/include/tetherkit/common/messages.def @@ -1,769 +1,481 @@ -// 全部面向用户的文案。**本文件刻意没有 include guard**:它是 X-macro 清单, -// 会被 i18n.h 与 i18n.cc 分别展开成枚举、中文表与英文表。 +// All user-facing strings. This file deliberately has no include guard: it is an +// X-macro list, expanded into the Msg enum (by tetherkit/common/i18n.h) and the +// message table (by src/common/i18n.cc). // -// 每行的三个参数: +// Each row takes exactly two arguments: // -// TETHERKIT_MESSAGE(标识, 中文, English) +// TETHERKIT_MESSAGE(identifier, English) // -// ★ 加一条文案的规矩 ★ +// Rules for adding a message: // -// 1. 标识按所属模块加前缀:kCommon* / kRndis* / kNet* / kUsb* / kCore* / -// kCapi* / kCli*,后半段用 CamelCase 概括语义,**不要**照抄中文原文; -// 2. 两种语言引用的**参数下标集合必须一致**。隐式的 `{}` 按出现先后依次算作 -// 0、1、2……,所以当译文需要换语序时,就把那一侧改成显式编号重排,例如 -// 中文 `"向 {} 写入 {} 字节"` 对应英文 `"write {1} bytes to {0}"`。 -// 注意同一个格式串里**不能**隐式与显式混用(std::format 的规矩), -// 但两种语言各用各的没有问题; -// 3. 新条目就近插进所属模块的段落里,段内大致按调用顺序排,方便对照代码; -// 4. 英文不要逐字直译。中文里的「:」在英文里是 ": ",中文的破折号在英文里 -// 通常拆成两句。 +// 1. Prefix the identifier with its module: kCommon* / kRndis* / kNet* / kUsb* / +// kCore* / kCapi* / kCli*; CamelCase for the rest; do not paraphrase the prose. +// 2. Placeholders: implicit {} are numbered in order of appearance (0, 1, 2, ...); +// implicit and explicit numbering must not be mixed within one string +// (std::format's rule). Reorder with explicit indices when needed, e.g. +// "write {1} bytes to {0}". +// 3. Insert new entries into their module's section, roughly in call order. // -// 规矩 2 由 tests/test_common_i18n.cc 逐条核对(占位符集合 + 试跑一次 -// vformat),写错会在 `ctest -R common.i18n` 里当场红掉。 +// Rule: every message's placeholders must parse cleanly and use contiguous +// indices starting at 0. Enforced entry by entry by tests/test_common_i18n.cc; +// a bad row turns `ctest -R common.i18n` red on the spot. +// +// How to add a language (the wiring deliberately stays in place with one +// language, so this is a bounded five-touch change, not a re-architecture): +// +// 1. Add a value (kSpanish = 1, say) to enum class Language in +// tetherkit/common/i18n.h, mirror it as TK_LANGUAGE_SPANISH in +// tk_language_t (include/tetherkit/capi/tetherkit_c.h), and mirror it in +// the Swift `Language` enum (gui/Sources/TetherKitIPC/Localization.swift). +// 2. Give every row of this file one more argument: the macro signature +// becomes (id, en, es) at the three extraction sites (the Msg enum in +// i18n.h, the table(s) in src/common/i18n.cc). +// 3. Emit one more table in src/common/i18n.cc next to kMessageTable and let +// TextIn() select it by Language. +// 4. Accept the new spelling and locale prefix in ParseLanguage() and +// LanguageFromLocaleString(). +// 5. Add the GUI menu entry and the per-key column in +// gui/Sources/TetherKitIPC/LocalizedStrings.swift, then extend the checks +// in tests/test_common_i18n.cc and the GUI LocalizationTests so that every +// language covers every message. // ============================================================================= -// common —— 基础设施层 +// common -- infrastructure layer // ============================================================================= -TETHERKIT_MESSAGE(kCommonLogFormatFailed, "<日志格式化失败>", "") -TETHERKIT_MESSAGE(kCommonThreadQosFailed, - "设置线程 QoS 失败(qos={}, rc={}),将使用默认调度策略", - "failed to set thread QoS (qos={}, rc={}); falling back to the default " +TETHERKIT_MESSAGE(kCommonLogFormatFailed, "") +TETHERKIT_MESSAGE(kCommonThreadQosFailed, "failed to set thread QoS (qos={}, rc={}); falling back to the default " "scheduling policy") // ============================================================================= -// net —— feth 虚拟网卡与 BPF 链路 +// net -- the feth virtual interface and the BPF link // ============================================================================= -TETHERKIT_MESSAGE(kNetLoopbackInjectedFailure, "loopback 后端按测试设置在第 {} 次写入时失败", - "loopback backend failed on write #{} as configured by the test") +TETHERKIT_MESSAGE(kNetLoopbackInjectedFailure, "loopback backend failed on write #{} as configured by the test") -// --- 通用(feth 与 BPF 共用)--- -TETHERKIT_MESSAGE(kNetInterfaceNameTooLong, "接口名 \"{}\" 过长", "interface name \"{}\" is too long") -TETHERKIT_MESSAGE(kNetInterfaceNameOverLimit, "接口名 \"{}\" 超过 {} 字节上限", - "interface name \"{}\" exceeds the {}-byte limit") -TETHERKIT_MESSAGE(kNetSysctlReadFailed, "读取 sysctl {} 失败", "failed to read sysctl {}") -TETHERKIT_MESSAGE(kNetUnknownValue, "未知", "unknown") +// --- shared (used by both feth and BPF) --- +TETHERKIT_MESSAGE(kNetInterfaceNameTooLong, "interface name \"{}\" is too long") +TETHERKIT_MESSAGE(kNetInterfaceNameOverLimit, "interface name \"{}\" exceeds the {}-byte limit") +TETHERKIT_MESSAGE(kNetSysctlReadFailed, "failed to read sysctl {}") +TETHERKIT_MESSAGE(kNetUnknownValue, "unknown") -// --- BPF 链路 --- -TETHERKIT_MESSAGE(kNetOptionalIoctlUnavailable, "可选 ioctl {} 不可用(errno={}),已跳过", - "optional ioctl {} is unavailable (errno={}); skipped") -TETHERKIT_MESSAGE(kNetFrameLimitBelowMinimum, "max_frame_bytes={} 小于最小以太帧长 {}", - "max_frame_bytes={} is smaller than the minimum Ethernet frame length {}") -TETHERKIT_MESSAGE(kNetBpfOpenDenied, - "打开 {} 被拒绝。/dev/bpf* 的权限是 0600 root:wheel," - "而 macOS 没有 FreeBSD 的 access_bpf 组可用,因此必须以 root 运行", - "permission denied opening {}. /dev/bpf* is 0600 root:wheel and macOS has no " +// --- BPF link --- +TETHERKIT_MESSAGE(kNetOptionalIoctlUnavailable, "optional ioctl {} is unavailable (errno={}); skipped") +TETHERKIT_MESSAGE(kNetFrameLimitBelowMinimum, "max_frame_bytes={} is smaller than the minimum Ethernet frame length {}") +TETHERKIT_MESSAGE(kNetBpfOpenDenied, "permission denied opening {}. /dev/bpf* is 0600 root:wheel and macOS has no " "equivalent of FreeBSD's access_bpf group, so this must run as root") -TETHERKIT_MESSAGE(kNetBpfNoDeviceAvailable, "找不到可用的 /dev/bpf* 设备(全部被占用或权限不足)", - "no usable /dev/bpf* device found (all busy, or insufficient permissions)") -TETHERKIT_MESSAGE(kNetBpfBufferClamped, - "内核把 BPF 缓冲从请求的 {} 字节调整为 {} 字节(受 sysctl " - "debug.bpf_bufsize_cap 限制)", - "the kernel clamped the BPF buffer from the requested {} bytes to {} bytes " +TETHERKIT_MESSAGE(kNetBpfNoDeviceAvailable, "no usable /dev/bpf* device found (all busy, or insufficient permissions)") +TETHERKIT_MESSAGE(kNetBpfBufferClamped, "the kernel clamped the BPF buffer from the requested {} bytes to {} bytes " "(limited by sysctl debug.bpf_bufsize_cap)") -TETHERKIT_MESSAGE(kNetBpfInterfaceMissing, "接口 {} 不存在,无法绑定 BPF", - "interface {} does not exist; cannot attach BPF") -TETHERKIT_MESSAGE(kNetBpfBindFailed, "把 BPF 绑定到 {} 失败", "failed to attach BPF to {}") -TETHERKIT_MESSAGE(kNetBpfWrongDataLinkType, - "接口 {} 的数据链路类型是 {},本驱动只支持以太网(DLT_EN10MB={})", - "interface {} has data link type {}; this driver only supports Ethernet " +TETHERKIT_MESSAGE(kNetBpfInterfaceMissing, "interface {} does not exist; cannot attach BPF") +TETHERKIT_MESSAGE(kNetBpfBindFailed, "failed to attach BPF to {}") +TETHERKIT_MESSAGE(kNetBpfWrongDataLinkType, "interface {} has data link type {}; this driver only supports Ethernet " "(DLT_EN10MB={})") -TETHERKIT_MESSAGE(kNetBpfReady, - "BPF 就绪:{} → {}(内核缓冲 {} KiB,单帧上限 {} 字节,批量写 {},时间戳 {})", - "BPF ready: {} -> {} (kernel buffer {} KiB, max frame {} bytes, batch write {}, " +TETHERKIT_MESSAGE(kNetBpfReady, "BPF ready: {} -> {} (kernel buffer {} KiB, max frame {} bytes, batch write {}, " "timestamps {})") -TETHERKIT_MESSAGE(kNetBpfBatchWriteEnabled, "已启用", "enabled") -TETHERKIT_MESSAGE(kNetBpfBatchWriteUnavailable, "不可用(逐帧写)", - "unavailable (writing one frame at a time)") -TETHERKIT_MESSAGE(kNetBpfTimestampDisabled, "已关闭", "off") -TETHERKIT_MESSAGE(kNetBpfTimestampEnabled, "保持开启", "left on") -TETHERKIT_MESSAGE(kNetBpfReadFailed, "从 {} 读取失败", "failed to read from {}") -TETHERKIT_MESSAGE(kNetBpfHeaderTooShort, - "BPF 记录的 bh_hdrlen={} 小于最小值 {},丢弃本次读取的剩余内容", - "BPF record has bh_hdrlen={}, below the minimum of {}; discarding the rest of " +TETHERKIT_MESSAGE(kNetBpfBatchWriteEnabled, "enabled") +TETHERKIT_MESSAGE(kNetBpfBatchWriteUnavailable, "unavailable (writing one frame at a time)") +TETHERKIT_MESSAGE(kNetBpfTimestampDisabled, "off") +TETHERKIT_MESSAGE(kNetBpfTimestampEnabled, "left on") +TETHERKIT_MESSAGE(kNetBpfReadFailed, "failed to read from {}") +TETHERKIT_MESSAGE(kNetBpfHeaderTooShort, "BPF record has bh_hdrlen={}, below the minimum of {}; discarding the rest of " "this read") -TETHERKIT_MESSAGE(kNetBpfRecordOutOfBounds, "BPF 记录越界(偏移 {} + {} > {}),丢弃剩余内容", - "BPF record runs past the buffer (offset {} + {} > {}); discarding the rest") -TETHERKIT_MESSAGE(kNetBpfSkipOversizedRecord, "跳过 BPF 记录:caplen={} datalen={}(上限 {})", - "skipping BPF record: caplen={} datalen={} (limit {})") -TETHERKIT_MESSAGE(kNetBpfBatchFrameLimit, - "单批帧数达到上限 {},本次读取剩余 {} 字节被丢弃,请增大 " - "max_frames_per_batch", - "hit the per-batch frame limit of {}; the remaining {} bytes of this read were " +TETHERKIT_MESSAGE(kNetBpfRecordOutOfBounds, "BPF record runs past the buffer (offset {} + {} > {}); discarding the rest") +TETHERKIT_MESSAGE(kNetBpfSkipOversizedRecord, "skipping BPF record: caplen={} datalen={} (limit {})") +TETHERKIT_MESSAGE(kNetBpfBatchFrameLimit, "hit the per-batch frame limit of {}; the remaining {} bytes of this read were " "dropped. Increase max_frames_per_batch") -TETHERKIT_MESSAGE(kNetBpfWriteFailed, "向 {} 写入 {} 字节的帧失败", - "failed to write a {1}-byte frame to {0}") -TETHERKIT_MESSAGE(kNetBpfBatchWriteFailed, "向 {} 批量写入 {} 帧({} 字节)失败", - "failed to batch-write {1} frames ({2} bytes) to {0}") +TETHERKIT_MESSAGE(kNetBpfWriteFailed, "failed to write a {1}-byte frame to {0}") +TETHERKIT_MESSAGE(kNetBpfBatchWriteFailed, "failed to batch-write {1} frames ({2} bytes) to {0}") -// --- feth 虚拟网卡 --- -TETHERKIT_MESSAGE(kNetIoctlSocketFailed, "创建用于接口 ioctl 的套接字失败", - "failed to create the socket used for interface ioctls") -TETHERKIT_MESSAGE(kNetFethMaxMtuInvalid, "net.link.fake.max_mtu 返回了非法值 {}", - "net.link.fake.max_mtu returned the invalid value {}") -TETHERKIT_MESSAGE(kNetSysctlUnreadableSkipped, "sysctl {} 不可读({}),跳过校验", - "sysctl {} is unreadable ({}); skipping the check") -TETHERKIT_MESSAGE(kNetSysctlMismatch, - "sysctl {0} 当前是 {1},要求 {2}。原因:{3}。\n" - " 这个开关在 feth 创建时被快照进接口,创建后再改无效,因此必须先修正:\n" - " sudo sysctl -w {0}={2}", - "sysctl {0} is currently {1} but must be {2}. Reason: {3}.\n" +// --- feth virtual interface --- +TETHERKIT_MESSAGE(kNetIoctlSocketFailed, "failed to create the socket used for interface ioctls") +TETHERKIT_MESSAGE(kNetFethMaxMtuInvalid, "net.link.fake.max_mtu returned the invalid value {}") +TETHERKIT_MESSAGE(kNetSysctlUnreadableSkipped, "sysctl {} is unreadable ({}); skipping the check") +TETHERKIT_MESSAGE(kNetSysctlMismatch, "sysctl {0} is currently {1} but must be {2}. Reason: {3}.\n" " feth snapshots this switch into the interface at creation time, so changing " "it afterwards has no effect. Fix it first:\n" " sudo sysctl -w {0}={2}") -TETHERKIT_MESSAGE(kNetSysctlWhyHwcsum, - "非 0 时出站帧的 IP/TCP/UDP 校验和是留给硬件计算的占位值,我们读到的帧校验和无效", - "when non-zero, IP/TCP/UDP checksums on outbound frames are placeholders left " +TETHERKIT_MESSAGE(kNetSysctlWhyHwcsum, "when non-zero, IP/TCP/UDP checksums on outbound frames are placeholders left " "for hardware to compute, so the frames we read have invalid checksums") -TETHERKIT_MESSAGE(kNetSysctlWhyFcs, "非 0 时会在帧尾追加 4 字节 CRC,转发到 USB 前需要额外剥离", - "when non-zero, a 4-byte CRC is appended to each frame and would have to be " +TETHERKIT_MESSAGE(kNetSysctlWhyFcs, "when non-zero, a 4-byte CRC is appended to each frame and would have to be " "stripped before forwarding over USB") -TETHERKIT_MESSAGE(kNetSysctlWhyTso, "非 0 时会收到最大 64KB 的 TSO 巨包,超出 RNDIS 单帧上限", - "when non-zero, TSO super-packets of up to 64 KB arrive, exceeding the RNDIS " +TETHERKIT_MESSAGE(kNetSysctlWhyTso, "when non-zero, TSO super-packets of up to 64 KB arrive, exceeding the RNDIS " "per-frame limit") -TETHERKIT_MESSAGE(kNetSysctlWhyLro, "非 0 时会收到被合并过的超长接收帧", - "when non-zero, coalesced oversized receive frames arrive") -TETHERKIT_MESSAGE(kNetSysctlWhyTrailer, "非 0 时帧尾带额外填充", - "when non-zero, frames carry extra trailing padding") -TETHERKIT_MESSAGE(kNetSysctlWhySeparateHeader, "非 0 时帧头与净荷分离,破坏连续内存假设", - "when non-zero, the frame header is separated from the payload, breaking the " +TETHERKIT_MESSAGE(kNetSysctlWhyLro, "when non-zero, coalesced oversized receive frames arrive") +TETHERKIT_MESSAGE(kNetSysctlWhyTrailer, "when non-zero, frames carry extra trailing padding") +TETHERKIT_MESSAGE(kNetSysctlWhySeparateHeader, "when non-zero, the frame header is separated from the payload, breaking the " "contiguous-memory assumption") -TETHERKIT_MESSAGE(kNetDestroyOrphanFailed, "销毁残留的虚拟网卡 {} 失败", - "failed to destroy leftover virtual interface {}") -TETHERKIT_MESSAGE(kNetDestroyedOrphan, "已销毁残留的虚拟网卡 {}", - "destroyed leftover virtual interface {}") -TETHERKIT_MESSAGE(kNetDestroyFailed, "销毁 {} 失败:{}", "failed to destroy {}: {}") -TETHERKIT_MESSAGE(kNetDestroyed, "已销毁虚拟网卡 {}", "destroyed virtual interface {}") -TETHERKIT_MESSAGE(kNetFethCreateNeedsRoot, "创建 feth 虚拟网卡需要 root 权限,请用 sudo 运行", - "creating a feth virtual interface requires root; run with sudo") -TETHERKIT_MESSAGE(kNetInterfaceExists, "接口 {} 已存在,请换一个名字或先销毁它", - "interface {} already exists; pick another name or destroy it first") -TETHERKIT_MESSAGE(kNetFethCreateFailed, "创建 feth 虚拟网卡 {} 失败", - "failed to create feth virtual interface {}") -TETHERKIT_MESSAGE(kNetCreated, "已创建虚拟网卡 {}", "created virtual interface {}") -TETHERKIT_MESSAGE(kNetPeerInvalidObject, "配对失败:接口对象无效", - "pairing failed: invalid interface object") -TETHERKIT_MESSAGE(kNetPeerNameTooLong, "peer 接口名 \"{}\" 过长", - "peer interface name \"{}\" is too long") -TETHERKIT_MESSAGE(kNetPeerNeedsRoot, "配对 feth 需要 root 权限,请用 sudo 运行", - "pairing feth interfaces requires root; run with sudo") -TETHERKIT_MESSAGE(kNetPeerRejected, - "把 {} 与 {} 配对被内核拒绝。可能原因:其中一方已有 peer、" - "对端不是 feth 接口、或当前 macOS 版本的 if_fake 私有 ABI 有变动", - "the kernel refused to pair {} with {}. Likely causes: one side already has a " +TETHERKIT_MESSAGE(kNetDestroyOrphanFailed, "failed to destroy leftover virtual interface {}") +TETHERKIT_MESSAGE(kNetDestroyedOrphan, "destroyed leftover virtual interface {}") +TETHERKIT_MESSAGE(kNetDestroyFailed, "failed to destroy {}: {}") +TETHERKIT_MESSAGE(kNetDestroyed, "destroyed virtual interface {}") +TETHERKIT_MESSAGE(kNetFethCreateNeedsRoot, "creating a feth virtual interface requires root; run with sudo") +TETHERKIT_MESSAGE(kNetInterfaceExists, "interface {} already exists; pick another name or destroy it first") +TETHERKIT_MESSAGE(kNetFethCreateFailed, "failed to create feth virtual interface {}") +TETHERKIT_MESSAGE(kNetCreated, "created virtual interface {}") +TETHERKIT_MESSAGE(kNetPeerInvalidObject, "pairing failed: invalid interface object") +TETHERKIT_MESSAGE(kNetPeerNameTooLong, "peer interface name \"{}\" is too long") +TETHERKIT_MESSAGE(kNetPeerNeedsRoot, "pairing feth interfaces requires root; run with sudo") +TETHERKIT_MESSAGE(kNetPeerRejected, "the kernel refused to pair {} with {}. Likely causes: one side already has a " "peer, the peer is not a feth interface, or the private if_fake ABI changed in " "this macOS version") -TETHERKIT_MESSAGE(kNetPeerFailed, "把 {} 与 {} 配对失败", "failed to pair {} with {}") -TETHERKIT_MESSAGE(kNetPeered, "已把 {} 与 {} 配对", "paired {} with {}") -TETHERKIT_MESSAGE(kNetUnpeerInvalidObject, "解绑失败:接口对象无效", - "unpairing failed: invalid interface object") -TETHERKIT_MESSAGE(kNetUnpeerIoctlWhat, "ioctl(SIOCSDRVSPEC, IF_FAKE_S_CMD_SET_PEER) 解绑", - "ioctl(SIOCSDRVSPEC, IF_FAKE_S_CMD_SET_PEER) to unpair") -TETHERKIT_MESSAGE(kNetQueryPeerInvalidObject, "查询 peer 失败:接口对象无效", - "peer query failed: invalid interface object") -TETHERKIT_MESSAGE(kNetSetMtuInvalidObject, "设置 MTU 失败:接口对象无效", - "setting the MTU failed: invalid interface object") -TETHERKIT_MESSAGE(kNetSetMtuRejected, - "把 {} 的 MTU 设为 {} 被拒绝(feth 的 MTU 上限是 {},由 sysctl " - "net.link.fake.max_mtu 决定,且该值在接口创建时已被快照)", - "refused to set the MTU of {} to {} (the feth MTU ceiling is {}, set by sysctl " +TETHERKIT_MESSAGE(kNetPeerFailed, "failed to pair {} with {}") +TETHERKIT_MESSAGE(kNetPeered, "paired {} with {}") +TETHERKIT_MESSAGE(kNetUnpeerInvalidObject, "unpairing failed: invalid interface object") +TETHERKIT_MESSAGE(kNetUnpeerIoctlWhat, "ioctl(SIOCSDRVSPEC, IF_FAKE_S_CMD_SET_PEER) to unpair") +TETHERKIT_MESSAGE(kNetQueryPeerInvalidObject, "peer query failed: invalid interface object") +TETHERKIT_MESSAGE(kNetSetMtuInvalidObject, "setting the MTU failed: invalid interface object") +TETHERKIT_MESSAGE(kNetSetMtuRejected, "refused to set the MTU of {} to {} (the feth MTU ceiling is {}, set by sysctl " "net.link.fake.max_mtu and snapshotted when the interface was created)") -TETHERKIT_MESSAGE(kNetSetMtuFailed, "把 {} 的 MTU 设为 {} 失败", - "failed to set the MTU of {} to {}") -TETHERKIT_MESSAGE(kNetSetMacInvalidObject, "设置 MAC 失败:接口对象无效", - "setting the MAC failed: invalid interface object") -TETHERKIT_MESSAGE(kNetSetMacNeedsRoot, "修改接口 MAC 需要 root 权限,请用 sudo 运行", - "changing an interface MAC requires root; run with sudo") -TETHERKIT_MESSAGE(kNetSetMacFailed, "把 {} 的 MAC 设为 {} 失败", - "failed to set the MAC of {} to {}") -TETHERKIT_MESSAGE(kNetMacSet, "已把 {} 的 MAC 设为 {}", "set the MAC of {} to {}") -TETHERKIT_MESSAGE(kNetGetifaddrsFailed, "getifaddrs 失败", "getifaddrs failed") -TETHERKIT_MESSAGE(kNetLinkAddressWrongLength, "{} 的链路地址长度是 {} 字节,不是 6 字节 MAC", - "the link address of {} is {} bytes, not a 6-byte MAC") -TETHERKIT_MESSAGE(kNetLinkAddressNotFound, "在 getifaddrs 结果里找不到接口 {} 的链路地址", - "no link address for interface {} in the getifaddrs results") -TETHERKIT_MESSAGE(kNetSetUpInvalidObject, "设置接口状态失败:接口对象无效", - "changing the interface state failed: invalid interface object") -TETHERKIT_MESSAGE(kNetSetUpNeedsRoot, "修改接口 UP/DOWN 需要 root 权限,请用 sudo 运行", - "bringing an interface up or down requires root; run with sudo") -TETHERKIT_MESSAGE(kNetSetUpFailed, "把 {} 置为 {} 失败", "failed to bring {} {}") -TETHERKIT_MESSAGE(kNetFethPairNeedsRoot, "创建 feth 虚拟网卡需要 root 权限。请用 sudo 运行 TetherKit。", - "creating feth virtual interfaces requires root. Run TetherKit with sudo.") -TETHERKIT_MESSAGE(kNetMtuExceedsFethLimit, - "请求的 MTU {} 超过 feth 上限 {}(sysctl net.link.fake.max_mtu)", - "the requested MTU {} exceeds the feth ceiling of {} (sysctl " +TETHERKIT_MESSAGE(kNetSetMtuFailed, "failed to set the MTU of {} to {}") +TETHERKIT_MESSAGE(kNetSetMacInvalidObject, "setting the MAC failed: invalid interface object") +TETHERKIT_MESSAGE(kNetSetMacNeedsRoot, "changing an interface MAC requires root; run with sudo") +TETHERKIT_MESSAGE(kNetSetMacFailed, "failed to set the MAC of {} to {}") +TETHERKIT_MESSAGE(kNetMacSet, "set the MAC of {} to {}") +TETHERKIT_MESSAGE(kNetGetifaddrsFailed, "getifaddrs failed") +TETHERKIT_MESSAGE(kNetLinkAddressWrongLength, "the link address of {} is {} bytes, not a 6-byte MAC") +TETHERKIT_MESSAGE(kNetLinkAddressNotFound, "no link address for interface {} in the getifaddrs results") +TETHERKIT_MESSAGE(kNetSetUpInvalidObject, "changing the interface state failed: invalid interface object") +TETHERKIT_MESSAGE(kNetSetUpNeedsRoot, "bringing an interface up or down requires root; run with sudo") +TETHERKIT_MESSAGE(kNetSetUpFailed, "failed to bring {} {}") +TETHERKIT_MESSAGE(kNetFethPairNeedsRoot, "creating feth virtual interfaces requires root. Run TetherKit with sudo.") +TETHERKIT_MESSAGE(kNetMtuExceedsFethLimit, "the requested MTU {} exceeds the feth ceiling of {} (sysctl " "net.link.fake.max_mtu)") -TETHERKIT_MESSAGE(kNetSameMacDadConflict, "两侧 MAC 相同({}),会触发 IPv6 DAD 地址冲突", - "both sides have the same MAC ({}), which would trigger an IPv6 DAD address " +TETHERKIT_MESSAGE(kNetSameMacDadConflict, "both sides have the same MAC ({}), which would trigger an IPv6 DAD address " "conflict") -TETHERKIT_MESSAGE(kNetFethPairReady, "虚拟网卡对就绪:系统侧 {} ←→ 驱动侧 {}(MTU {})", - "virtual interface pair ready: system side {} <-> driver side {} (MTU {})") +TETHERKIT_MESSAGE(kNetFethPairReady, "virtual interface pair ready: system side {} <-> driver side {} (MTU {})") // ============================================================================= -// rndis —— 协议编解码与状态机 +// rndis -- protocol codec and state machine // ============================================================================= -// --- 通用占位 --- -TETHERKIT_MESSAGE(kRndisUnknownStatus, "未知状态", "unknown status") -TETHERKIT_MESSAGE(kRndisUnknownMessage, "未知消息", "unknown message") -TETHERKIT_MESSAGE(kRndisUnknownMessageType, "未知消息类型", "unknown message type") -TETHERKIT_MESSAGE(kRndisUnknown, "未知", "unknown") -TETHERKIT_MESSAGE(kRndisResponse, "响应", "a response") +// --- generic placeholders --- +TETHERKIT_MESSAGE(kRndisUnknownStatus, "unknown status") +TETHERKIT_MESSAGE(kRndisUnknownMessage, "unknown message") +TETHERKIT_MESSAGE(kRndisUnknownMessageType, "unknown message type") +TETHERKIT_MESSAGE(kRndisUnknown, "unknown") +TETHERKIT_MESSAGE(kRndisResponse, "a response") -// --- 状态名 --- -TETHERKIT_MESSAGE(kRndisStateUninitialized, "未初始化", "uninitialized") -TETHERKIT_MESSAGE(kRndisStateInitializing, "初始化中", "initializing") -TETHERKIT_MESSAGE(kRndisStateInitialized, "已初始化", "initialized") -TETHERKIT_MESSAGE(kRndisStateDataInitialized, "数据已就绪", "data ready") -TETHERKIT_MESSAGE(kRndisStateHalting, "正在终止", "halting") -TETHERKIT_MESSAGE(kRndisStateUnknown, "未知状态", "unknown state") +// --- state names --- +TETHERKIT_MESSAGE(kRndisStateUninitialized, "uninitialized") +TETHERKIT_MESSAGE(kRndisStateInitializing, "initializing") +TETHERKIT_MESSAGE(kRndisStateInitialized, "initialized") +TETHERKIT_MESSAGE(kRndisStateDataInitialized, "data ready") +TETHERKIT_MESSAGE(kRndisStateHalting, "halting") +TETHERKIT_MESSAGE(kRndisStateUnknown, "unknown state") -// --- 数据包解码的拒绝原因 --- -TETHERKIT_MESSAGE(kRndisMalformedNone, "无", "none") -TETHERKIT_MESSAGE(kRndisMalformedNotPacketMessage, "MessageType 不是 PACKET_MSG", - "MessageType is not PACKET_MSG") -TETHERKIT_MESSAGE(kRndisMalformedMessageTooShort, "MessageLength 小于 44", - "MessageLength is below 44") -TETHERKIT_MESSAGE(kRndisMalformedMessageOverruns, "MessageLength 超出传输长度", - "MessageLength runs past the transfer") -TETHERKIT_MESSAGE(kRndisMalformedDataOutOfBounds, "DataOffset+DataLength 越界", - "DataOffset+DataLength is out of bounds") -TETHERKIT_MESSAGE(kRndisMalformedFrameTooShort, "DataLength 小于以太头长度", - "DataLength is below the Ethernet header length") -TETHERKIT_MESSAGE(kRndisMalformedFrameTooLong, "DataLength 超过单帧上限", - "DataLength exceeds the per-frame limit") -TETHERKIT_MESSAGE(kRndisMalformedUnknown, "未知", "unknown") +// --- data packet decode rejection reasons --- +TETHERKIT_MESSAGE(kRndisMalformedNone, "none") +TETHERKIT_MESSAGE(kRndisMalformedNotPacketMessage, "MessageType is not PACKET_MSG") +TETHERKIT_MESSAGE(kRndisMalformedMessageTooShort, "MessageLength is below 44") +TETHERKIT_MESSAGE(kRndisMalformedMessageOverruns, "MessageLength runs past the transfer") +TETHERKIT_MESSAGE(kRndisMalformedDataOutOfBounds, "DataOffset+DataLength is out of bounds") +TETHERKIT_MESSAGE(kRndisMalformedFrameTooShort, "DataLength is below the Ethernet header length") +TETHERKIT_MESSAGE(kRndisMalformedFrameTooLong, "DataLength exceeds the per-frame limit") +TETHERKIT_MESSAGE(kRndisMalformedUnknown, "unknown") -// --- 控制消息编解码 --- -TETHERKIT_MESSAGE(kRndisBufferTooShort, "{} 长度不足:需要 {} 字节,实际 {} 字节", - "{} is too short: need {} bytes, got {}") -TETHERKIT_MESSAGE(kRndisEncodeBufferTooSmall, "编码 {} 的缓冲区不足:需要 {} 字节,实际 {} 字节", - "the buffer for encoding {} is too small: need {} bytes, got {}") -TETHERKIT_MESSAGE(kRndisUnexpectedMessageType, "期望 {},实际收到 {}({:#010x})", - "expected {}, got {} ({:#010x})") -TETHERKIT_MESSAGE(kRndisInconsistentMessageLength, - "{} 的 MessageLength={} 不自洽(缓冲 {} 字节,最小 {} 字节)", - "MessageLength={1} of {0} is inconsistent (buffer is {2} bytes, minimum is {3})") -TETHERKIT_MESSAGE(kRndisInlineBufferOutOfBounds, - "{} 越界:偏移 {}(相对值 {})+ 长度 {} 超出 MessageLength {}", - "{} is out of bounds: offset {} (relative {}) + length {} exceeds MessageLength " +// --- control message codec --- +TETHERKIT_MESSAGE(kRndisBufferTooShort, "{} is too short: need {} bytes, got {}") +TETHERKIT_MESSAGE(kRndisEncodeBufferTooSmall, "the buffer for encoding {} is too small: need {} bytes, got {}") +TETHERKIT_MESSAGE(kRndisUnexpectedMessageType, "expected {}, got {} ({:#010x})") +TETHERKIT_MESSAGE(kRndisInconsistentMessageLength, "MessageLength={1} of {0} is inconsistent (buffer is {2} bytes, minimum is {3})") +TETHERKIT_MESSAGE(kRndisInlineBufferOutOfBounds, "{} is out of bounds: offset {} (relative {}) + length {} exceeds MessageLength " "{}") -TETHERKIT_MESSAGE(kRndisWhatMessageHeader, "RNDIS 消息头", "the RNDIS message header") -TETHERKIT_MESSAGE(kRndisWhatQueryCmpltInfoBuffer, "QUERY_CMPLT 的 InformationBuffer", - "the InformationBuffer of QUERY_CMPLT") -TETHERKIT_MESSAGE(kRndisWhatOidUint32, "OID 的 LE32 返回值", "the LE32 return value of an OID") -TETHERKIT_MESSAGE(kRndisWhatOidMac, "OID 的 MAC 返回值", "the MAC return value of an OID") -TETHERKIT_MESSAGE(kRndisStatusSuffix, "{}:{}", "{}: {}") -TETHERKIT_MESSAGE(kRndisInitRejected, "设备拒绝 RNDIS 初始化", - "the device rejected RNDIS initialization") -TETHERKIT_MESSAGE(kRndisUnsupportedMedium, "设备汇报的介质是 {:#x},本驱动只支持 802.3 以太网(0)", - "the device reports medium {:#x}; this driver only supports 802.3 Ethernet (0)") -TETHERKIT_MESSAGE(kRndisConnectionOriented, - "设备是面向连接(CONDIS)类型,本驱动只支持无连接的 802.3 设备", - "the device is connection-oriented (CONDIS); this driver only supports " +TETHERKIT_MESSAGE(kRndisWhatMessageHeader, "the RNDIS message header") +TETHERKIT_MESSAGE(kRndisWhatQueryCmpltInfoBuffer, "the InformationBuffer of QUERY_CMPLT") +TETHERKIT_MESSAGE(kRndisWhatOidUint32, "the LE32 return value of an OID") +TETHERKIT_MESSAGE(kRndisWhatOidMac, "the MAC return value of an OID") +TETHERKIT_MESSAGE(kRndisStatusSuffix, "{}: {}") +TETHERKIT_MESSAGE(kRndisInitRejected, "the device rejected RNDIS initialization") +TETHERKIT_MESSAGE(kRndisUnsupportedMedium, "the device reports medium {:#x}; this driver only supports 802.3 Ethernet (0)") +TETHERKIT_MESSAGE(kRndisConnectionOriented, "the device is connection-oriented (CONDIS); this driver only supports " "connectionless 802.3 devices") -TETHERKIT_MESSAGE(kRndisNoConnectionlessFlag, - "设备的 DeviceFlags={:#x} 未置 CONNECTIONLESS 位,按无连接设备继续处理", - "the device has DeviceFlags={:#x} without the CONNECTIONLESS bit; continuing as " +TETHERKIT_MESSAGE(kRndisNoConnectionlessFlag, "the device has DeviceFlags={:#x} without the CONNECTIONLESS bit; continuing as " "if it were connectionless") -TETHERKIT_MESSAGE(kRndisUnsupportedMajorVersion, "设备的 RNDIS 主版本号是 {},本驱动只实现 {}", - "the device reports RNDIS major version {}; this driver only implements {}") -TETHERKIT_MESSAGE(kRndisAlignmentFactorClamped, "设备汇报的 PacketAlignmentFactor={} 超出合法上界 {},已钳位", - "the device reports PacketAlignmentFactor={}, above the legal ceiling of {}; " +TETHERKIT_MESSAGE(kRndisUnsupportedMajorVersion, "the device reports RNDIS major version {}; this driver only implements {}") +TETHERKIT_MESSAGE(kRndisAlignmentFactorClamped, "the device reports PacketAlignmentFactor={}, above the legal ceiling of {}; " "clamped") -TETHERKIT_MESSAGE(kRndisMaxTransferTooSmall, - "设备汇报的 MaxTransferSize={} 装不下 {} 字节的 RNDIS+以太头部", - "the device reports MaxTransferSize={}, too small for the {}-byte RNDIS + " +TETHERKIT_MESSAGE(kRndisMaxTransferTooSmall, "the device reports MaxTransferSize={}, too small for the {}-byte RNDIS + " "Ethernet headers") -TETHERKIT_MESSAGE(kRndisMaxTransferClampedToHost, - "设备汇报 MaxTransferSize={},超出 host 缓冲上限 {},按 host 上限使用", - "the device reports MaxTransferSize={}, above the host buffer ceiling of {}; " +TETHERKIT_MESSAGE(kRndisMaxTransferClampedToHost, "the device reports MaxTransferSize={}, above the host buffer ceiling of {}; " "using the host ceiling") -TETHERKIT_MESSAGE(kRndisMtuLoweredForTransferSize, - "设备 MaxTransferSize={} 小于满帧所需 {},MTU 由 {} 下调为 {}", - "the device's MaxTransferSize={} is below the {} needed for a full frame; " +TETHERKIT_MESSAGE(kRndisMtuLoweredForTransferSize, "the device's MaxTransferSize={} is below the {} needed for a full frame; " "lowering the MTU from {} to {}") -TETHERKIT_MESSAGE(kRndisExpectedIndicateStatus, "期望 REMOTE_NDIS_INDICATE_STATUS_MSG,实际 {:#010x}", - "expected REMOTE_NDIS_INDICATE_STATUS_MSG, got {:#010x}") -TETHERKIT_MESSAGE(kRndisOidCounterBadLength, "OID 计数器返回值长度 {} 字节,既非 4 也非 8", - "the OID counter returned {} bytes, which is neither 4 nor 8") -TETHERKIT_MESSAGE(kRndisIndicateStatusBufferOutOfBounds, - "INDICATE_STATUS 的 StatusBufferOffset={} / Length={} 两种基准点解释均越界" - "(MessageLength={}),已忽略状态缓冲区", - "the StatusBufferOffset={} / Length={} of INDICATE_STATUS is out of bounds under " +TETHERKIT_MESSAGE(kRndisExpectedIndicateStatus, "expected REMOTE_NDIS_INDICATE_STATUS_MSG, got {:#010x}") +TETHERKIT_MESSAGE(kRndisOidCounterBadLength, "the OID counter returned {} bytes, which is neither 4 nor 8") +TETHERKIT_MESSAGE(kRndisIndicateStatusBufferOutOfBounds, "the StatusBufferOffset={} / Length={} of INDICATE_STATUS is out of bounds under " "both base-point interpretations (MessageLength={}); ignoring the status buffer") -// --- 状态机 --- -TETHERKIT_MESSAGE(kRndisStateTransition, "RNDIS 状态:{} → {}", "RNDIS state: {} -> {}") -TETHERKIT_MESSAGE(kRndisControlMessageReceived, "控制通道收到 {}({:#010x}),{} 字节", - "control channel received {} ({:#010x}), {} bytes") -TETHERKIT_MESSAGE(kRndisCondisMessageReceived, - "设备发来面向连接(CONDIS)消息 {},本驱动只支持无连接的 802.3 设备", - "the device sent the connection-oriented (CONDIS) message {}; this driver only " +// --- state machine --- +TETHERKIT_MESSAGE(kRndisStateTransition, "RNDIS state: {} -> {}") +TETHERKIT_MESSAGE(kRndisControlMessageReceived, "control channel received {} ({:#010x}), {} bytes") +TETHERKIT_MESSAGE(kRndisCondisMessageReceived, "the device sent the connection-oriented (CONDIS) message {}; this driver only " "supports connectionless 802.3 devices") -TETHERKIT_MESSAGE(kRndisDiscardUnexpectedMessage, "丢弃一条非预期的控制消息 {}({:#010x})", - "discarding an unexpected control message {} ({:#010x})") -TETHERKIT_MESSAGE(kRndisNotificationReceived, "收到 RESPONSE_AVAILABLE 通知", - "received a RESPONSE_AVAILABLE notification") -TETHERKIT_MESSAGE(kRndisWaitReplyTimeout, "等待 {} 超时(轮询 {} 次 × {} ms)", - "timed out waiting for {} ({} polls x {} ms)") -TETHERKIT_MESSAGE(kRndisIndicateStatusParseFailed, "解析 INDICATE_STATUS 失败:{}", - "failed to parse INDICATE_STATUS: {}") -TETHERKIT_MESSAGE(kRndisDeviceIndicatedWithDiagnostics, - "设备通报 {}({:#010x}),诊断信息:DiagStatus={:#010x},出错偏移={}", - "the device reported {} ({:#010x}); diagnostics: DiagStatus={:#010x}, error " +TETHERKIT_MESSAGE(kRndisDiscardUnexpectedMessage, "discarding an unexpected control message {} ({:#010x})") +TETHERKIT_MESSAGE(kRndisNotificationReceived, "received a RESPONSE_AVAILABLE notification") +TETHERKIT_MESSAGE(kRndisWaitReplyTimeout, "timed out waiting for {} ({} polls x {} ms)") +TETHERKIT_MESSAGE(kRndisIndicateStatusParseFailed, "failed to parse INDICATE_STATUS: {}") +TETHERKIT_MESSAGE(kRndisDeviceIndicatedWithDiagnostics, "the device reported {} ({:#010x}); diagnostics: DiagStatus={:#010x}, error " "offset={}") -TETHERKIT_MESSAGE(kRndisDeviceIndicated, "设备通报 {}({:#010x})", "the device reported {} ({:#010x})") -TETHERKIT_MESSAGE(kRndisKeepAliveMsgTooShort, "设备发来的 KEEPALIVE_MSG 长度不足 12 字节", - "the KEEPALIVE_MSG from the device is shorter than 12 bytes") -TETHERKIT_MESSAGE(kRndisDeviceKeepAlive, "设备发起保活(RequestId={}),回复 KEEPALIVE_CMPLT", - "the device initiated a keep-alive (RequestId={}); replying with KEEPALIVE_CMPLT") -TETHERKIT_MESSAGE(kRndisOptionalOidQueryFailed, "查询可选 OID {} 失败(不致命):{}", - "failed to query optional OID {} (not fatal): {}") -TETHERKIT_MESSAGE(kRndisOidQueryFailed, "查询 OID {} 失败", "failed to query OID {}") -TETHERKIT_MESSAGE(kRndisOptionalOidUnsupported, "设备不支持可选 OID {}({})", - "the device does not support optional OID {} ({})") -TETHERKIT_MESSAGE(kRndisOidQueryRejected, "查询 OID {} 被拒绝:{}", "query of OID {} was rejected: {}") -TETHERKIT_MESSAGE(kRndisOidSetRejected, "设置 OID {} = {:#x} 被拒绝:{}", - "setting OID {} = {:#x} was rejected: {}") -TETHERKIT_MESSAGE(kRndisOidSet, "已设置 OID {} = {:#x}", "set OID {} = {:#x}") -TETHERKIT_MESSAGE(kRndisPermanentMac, "设备永久 MAC:{}", "device permanent MAC: {}") -TETHERKIT_MESSAGE(kRndisMtuLoweredToDeviceFrameSize, "设备汇报最大帧长 {} 小于协商 MTU {},下调 MTU", - "the device reports a maximum frame size of {}, below the negotiated MTU of {}; " +TETHERKIT_MESSAGE(kRndisDeviceIndicated, "the device reported {} ({:#010x})") +TETHERKIT_MESSAGE(kRndisKeepAliveMsgTooShort, "the KEEPALIVE_MSG from the device is shorter than 12 bytes") +TETHERKIT_MESSAGE(kRndisDeviceKeepAlive, "the device initiated a keep-alive (RequestId={}); replying with KEEPALIVE_CMPLT") +TETHERKIT_MESSAGE(kRndisOptionalOidQueryFailed, "failed to query optional OID {} (not fatal): {}") +TETHERKIT_MESSAGE(kRndisOidQueryFailed, "failed to query OID {}") +TETHERKIT_MESSAGE(kRndisOptionalOidUnsupported, "the device does not support optional OID {} ({})") +TETHERKIT_MESSAGE(kRndisOidQueryRejected, "query of OID {} was rejected: {}") +TETHERKIT_MESSAGE(kRndisOidSetRejected, "setting OID {} = {:#x} was rejected: {}") +TETHERKIT_MESSAGE(kRndisOidSet, "set OID {} = {:#x}") +TETHERKIT_MESSAGE(kRndisPermanentMac, "device permanent MAC: {}") +TETHERKIT_MESSAGE(kRndisMtuLoweredToDeviceFrameSize, "the device reports a maximum frame size of {}, below the negotiated MTU of {}; " "lowering the MTU") -TETHERKIT_MESSAGE(kRndisDeviceInfo, - "设备信息:MAC {},链路 {:.0f} Mbps,最大帧长 {},介质 {},厂商 {:#x} \"{}\"", - "device info: MAC {}, link {:.0f} Mbps, max frame {}, medium {}, vendor {:#x} " +TETHERKIT_MESSAGE(kRndisDeviceInfo, "device info: MAC {}, link {:.0f} Mbps, max frame {}, medium {}, vendor {:#x} " "\"{}\"") -TETHERKIT_MESSAGE(kRndisStartWrongState, "Start() 只能在未初始化状态下调用,当前是「{}」", - "Start() may only be called from the uninitialized state; the current state is " +TETHERKIT_MESSAGE(kRndisStartWrongState, "Start() may only be called from the uninitialized state; the current state is " "\"{}\"") -TETHERKIT_MESSAGE(kRndisInitFailed, "RNDIS 初始化失败", "RNDIS initialization failed") -TETHERKIT_MESSAGE(kRndisMaxPacketsClamped, "设备汇报 MaxPacketsPerMessage={},按调用方要求钳到 {}", - "the device reports MaxPacketsPerMessage={}; clamping to {} as the caller " +TETHERKIT_MESSAGE(kRndisInitFailed, "RNDIS initialization failed") +TETHERKIT_MESSAGE(kRndisMaxPacketsClamped, "the device reports MaxPacketsPerMessage={}; clamping to {} as the caller " "requested") -TETHERKIT_MESSAGE(kRndisNegotiated, - "RNDIS 协商完成:版本 {}.{},MTU {},设备聚合上限 {} 字节 / {} 包,TX 对齐 {} 字节", - "RNDIS negotiation complete: version {}.{}, MTU {}, device aggregation limit {} " +TETHERKIT_MESSAGE(kRndisNegotiated, "RNDIS negotiation complete: version {}.{}, MTU {}, device aggregation limit {} " "bytes / {} packets, TX alignment {} bytes") -TETHERKIT_MESSAGE(kRndisPacketFilterFailed, "设置包过滤失败,数据通道无法开启", - "failed to set the packet filter; the data channel cannot be opened") -TETHERKIT_MESSAGE(kRndisDrainControlFailed, "排空控制通道推送消息失败", - "failed to drain unsolicited control-channel messages") -TETHERKIT_MESSAGE(kRndisKeepAliveFailed, "保活失败(连续 {} / {} 次):{}", - "keep-alive failed ({} consecutive of {}): {}") -TETHERKIT_MESSAGE(kRndisKeepAliveDeadLink, "连续 {} 次保活失败,判定链路已死", - "{} consecutive keep-alive failures; treating the link as dead") -TETHERKIT_MESSAGE(kRndisKeepAliveRejected, "设备的 KEEPALIVE_CMPLT 返回 {},尝试软复位", - "the device's KEEPALIVE_CMPLT returned {}; attempting a soft reset") -TETHERKIT_MESSAGE(kRndisKeepAliveOk, "保活正常", "keep-alive OK") -TETHERKIT_MESSAGE(kRndisResetStarted, "发起 RNDIS 软复位", "starting an RNDIS soft reset") -TETHERKIT_MESSAGE(kRndisResetRejected, "软复位被拒绝:{}", "the soft reset was rejected: {}") -TETHERKIT_MESSAGE(kRndisResetDone, "软复位完成,寻址信息{}", - "soft reset complete; addressing information {}") -TETHERKIT_MESSAGE(kRndisAddressingLost, "已丢失,需要重放", "was lost and must be replayed") -TETHERKIT_MESSAGE(kRndisAddressingKept, "保持有效", "is still valid") -TETHERKIT_MESSAGE(kRndisPacketFilterReplayed, "已重放包过滤设置 {:#x}", - "replayed the packet filter setting {:#x}") -TETHERKIT_MESSAGE(kRndisEncodeHaltFailed, "编码 HALT_MSG 失败:{}", "failed to encode HALT_MSG: {}") -TETHERKIT_MESSAGE(kRndisSendHaltFailed, "发送 HALT_MSG 失败(设备可能已断开):{}", - "failed to send HALT_MSG (the device may already be disconnected): {}") -TETHERKIT_MESSAGE(kRndisHaltSent, "已发送 HALT_MSG", "sent HALT_MSG") -TETHERKIT_MESSAGE(kRndisClearPacketFilterFailed, "清零包过滤失败(继续发 HALT):{}", - "failed to clear the packet filter (sending HALT anyway): {}") +TETHERKIT_MESSAGE(kRndisPacketFilterFailed, "failed to set the packet filter; the data channel cannot be opened") +TETHERKIT_MESSAGE(kRndisDrainControlFailed, "failed to drain unsolicited control-channel messages") +TETHERKIT_MESSAGE(kRndisKeepAliveFailed, "keep-alive failed ({} consecutive of {}): {}") +TETHERKIT_MESSAGE(kRndisKeepAliveDeadLink, "{} consecutive keep-alive failures; treating the link as dead") +TETHERKIT_MESSAGE(kRndisKeepAliveRejected, "the device's KEEPALIVE_CMPLT returned {}; attempting a soft reset") +TETHERKIT_MESSAGE(kRndisKeepAliveOk, "keep-alive OK") +TETHERKIT_MESSAGE(kRndisResetStarted, "starting an RNDIS soft reset") +TETHERKIT_MESSAGE(kRndisResetRejected, "the soft reset was rejected: {}") +TETHERKIT_MESSAGE(kRndisResetDone, "soft reset complete; addressing information {}") +TETHERKIT_MESSAGE(kRndisAddressingLost, "was lost and must be replayed") +TETHERKIT_MESSAGE(kRndisAddressingKept, "is still valid") +TETHERKIT_MESSAGE(kRndisPacketFilterReplayed, "replayed the packet filter setting {:#x}") +TETHERKIT_MESSAGE(kRndisEncodeHaltFailed, "failed to encode HALT_MSG: {}") +TETHERKIT_MESSAGE(kRndisSendHaltFailed, "failed to send HALT_MSG (the device may already be disconnected): {}") +TETHERKIT_MESSAGE(kRndisHaltSent, "sent HALT_MSG") +TETHERKIT_MESSAGE(kRndisClearPacketFilterFailed, "failed to clear the packet filter (sending HALT anyway): {}") // ============================================================================= -// usb —— 上下文、设备发现与传输通道 +// usb -- context, device discovery, and transfer channels // ============================================================================= -// --- 上下文 --- -TETHERKIT_MESSAGE(kUsbInitFailed, "libusb_init 失败", "libusb_init failed") -TETHERKIT_MESSAGE(kUsbInitialized, "libusb 已初始化:{}(热插拔 {})", - "libusb initialized: {} (hotplug {})") -TETHERKIT_MESSAGE(kUsbHotplugSupported, "支持", "supported") -TETHERKIT_MESSAGE(kUsbHotplugUnsupported, "不支持", "unsupported") -TETHERKIT_MESSAGE(kUsbContextReleased, "libusb 上下文已释放", "released the libusb context") -TETHERKIT_MESSAGE(kUsbUnknownVersion, "未知版本", "unknown version") -TETHERKIT_MESSAGE(kUsbEventThreadStarted, "libusb 事件线程已启动", "libusb event thread started") -TETHERKIT_MESSAGE(kUsbEventLoopNoDevice, "libusb 事件循环收到 NO_DEVICE,继续等待", - "the libusb event loop got NO_DEVICE; continuing to wait") -TETHERKIT_MESSAGE(kUsbHandleEventsFailed, "libusb_handle_events 返回 {}({}),事件线程退出", - "libusb_handle_events returned {} ({}); the event thread is exiting") -TETHERKIT_MESSAGE(kUsbEventThreadExited, "libusb 事件线程已退出", "libusb event thread exited") +// --- context --- +TETHERKIT_MESSAGE(kUsbInitFailed, "libusb_init failed") +TETHERKIT_MESSAGE(kUsbInitialized, "libusb initialized: {} (hotplug {})") +TETHERKIT_MESSAGE(kUsbHotplugSupported, "supported") +TETHERKIT_MESSAGE(kUsbHotplugUnsupported, "unsupported") +TETHERKIT_MESSAGE(kUsbContextReleased, "released the libusb context") +TETHERKIT_MESSAGE(kUsbUnknownVersion, "unknown version") +TETHERKIT_MESSAGE(kUsbEventThreadStarted, "libusb event thread started") +TETHERKIT_MESSAGE(kUsbEventLoopNoDevice, "the libusb event loop got NO_DEVICE; continuing to wait") +TETHERKIT_MESSAGE(kUsbHandleEventsFailed, "libusb_handle_events returned {} ({}); the event thread is exiting") +TETHERKIT_MESSAGE(kUsbEventThreadExited, "libusb event thread exited") -// --- 设备发现与声明 --- -TETHERKIT_MESSAGE(kUsbGetDeviceListFailed, "libusb_get_device_list 失败", - "libusb_get_device_list failed") -TETHERKIT_MESSAGE(kUsbSkippedAcmModem, - "{:04x}:{:04x} 接口 {} 签名像 RNDIS,但带非零 ACM bmCapabilities," - "判定为真 cdc-acm 调制解调器,跳过", - "{:04x}:{:04x} interface {} looks like RNDIS by signature but carries non-zero " +// --- device discovery and claiming --- +TETHERKIT_MESSAGE(kUsbGetDeviceListFailed, "libusb_get_device_list failed") +TETHERKIT_MESSAGE(kUsbSkippedAcmModem, "{:04x}:{:04x} interface {} looks like RNDIS by signature but carries non-zero " "ACM bmCapabilities; treating it as a real cdc-acm modem and skipping it") -TETHERKIT_MESSAGE(kUsbNoPairedDataInterface, - "{} 的接口 {} 是 RNDIS 通信接口,但找不到配对的数据接口,跳过", - "interface {1} of {0} is an RNDIS control interface but has no matching data " +TETHERKIT_MESSAGE(kUsbNoPairedDataInterface, "interface {1} of {0} is an RNDIS control interface but has no matching data " "interface; skipping it") -TETHERKIT_MESSAGE(kUsbDeviceFound, "发现 RNDIS 设备 {}:通信接口 {}、数据接口 {},签名 {:02x}/{:02x}/{:02x}{}", - "found RNDIS device {}: control interface {}, data interface {}, signature " +TETHERKIT_MESSAGE(kUsbDeviceFound, "found RNDIS device {}: control interface {}, data interface {}, signature " "{:02x}/{:02x}/{:02x}{}") -TETHERKIT_MESSAGE(kUsbViaAndroidQuirk, "(走了 Android quirk 兜底)", - " (matched via the Android quirk fallback)") -TETHERKIT_MESSAGE(kUsbDeviceGone, "设备 {} 已不在总线上", "device {} is no longer on the bus") -TETHERKIT_MESSAGE(kUsbOpenFailed, "打开设备 {} 失败", "failed to open device {}") -TETHERKIT_MESSAGE(kUsbClaimFailed, "声明{}(接口 {})失败", "failed to claim {} (interface {})") -TETHERKIT_MESSAGE(kUsbClaimBusyHint, - "接口被内核驱动独占。macOS 本身没有 RNDIS 驱动,出现这种情况通常意味着" - "装了 HoRNDIS 之类的第三方 kext,或有别的用户态程序已经声明了该接口", - "the interface is held by a kernel driver. macOS has no RNDIS driver of its own, " +TETHERKIT_MESSAGE(kUsbViaAndroidQuirk, " (matched via the Android quirk fallback)") +TETHERKIT_MESSAGE(kUsbDeviceGone, "device {} is no longer on the bus") +TETHERKIT_MESSAGE(kUsbOpenFailed, "failed to open device {}") +TETHERKIT_MESSAGE(kUsbClaimFailed, "failed to claim {} (interface {})") +TETHERKIT_MESSAGE(kUsbClaimBusyHint, "the interface is held by a kernel driver. macOS has no RNDIS driver of its own, " "so this usually means a third-party kext such as HoRNDIS is installed, or " "another user-space program already claimed the interface") -TETHERKIT_MESSAGE(kUsbClaimNotFoundHint, "接口不存在。可能是设备切换了配置,或描述符解析有误", - "the interface does not exist. The device may have switched configuration, or " +TETHERKIT_MESSAGE(kUsbClaimNotFoundHint, "the interface does not exist. The device may have switched configuration, or " "the descriptors were parsed incorrectly") -TETHERKIT_MESSAGE(kUsbControlInterface, "RNDIS 通信接口", "the RNDIS control interface") -TETHERKIT_MESSAGE(kUsbDataInterface, "RNDIS 数据接口", "the RNDIS data interface") -TETHERKIT_MESSAGE(kUsbReadActiveConfigFailed, "读取活动配置描述符失败", - "failed to read the active configuration descriptor") -TETHERKIT_MESSAGE(kUsbClaimed, - "已声明 {}({}):bulk IN 0x{:02x} / OUT 0x{:02x}(wMaxPacketSize {})," - "中断 IN {}", - "claimed {} ({}): bulk IN 0x{:02x} / OUT 0x{:02x} (wMaxPacketSize {}), " +TETHERKIT_MESSAGE(kUsbControlInterface, "the RNDIS control interface") +TETHERKIT_MESSAGE(kUsbDataInterface, "the RNDIS data interface") +TETHERKIT_MESSAGE(kUsbReadActiveConfigFailed, "failed to read the active configuration descriptor") +TETHERKIT_MESSAGE(kUsbClaimed, "claimed {} ({}): bulk IN 0x{:02x} / OUT 0x{:02x} (wMaxPacketSize {}), " "interrupt IN {}") -TETHERKIT_MESSAGE(kUsbNoInterruptEndpointShort, "无(将退化为轮询控制端点)", - "none (falling back to polling the control endpoint)") -TETHERKIT_MESSAGE(kUsbClosed, "已关闭设备 {}", "closed device {}") -TETHERKIT_MESSAGE(kUsbBulkEndpointsMissing, - "数据接口 {} 上找不到成对的 bulk 端点(IN=0x{:02x}, OUT=0x{:02x})", - "no matching pair of bulk endpoints on data interface {} (IN=0x{:02x}, " +TETHERKIT_MESSAGE(kUsbNoInterruptEndpointShort, "none (falling back to polling the control endpoint)") +TETHERKIT_MESSAGE(kUsbClosed, "closed device {}") +TETHERKIT_MESSAGE(kUsbBulkEndpointsMissing, "no matching pair of bulk endpoints on data interface {} (IN=0x{:02x}, " "OUT=0x{:02x})") -TETHERKIT_MESSAGE(kUsbBulkMaxPacketSizeZero, "bulk 端点的 wMaxPacketSize 为 0", - "the bulk endpoint has wMaxPacketSize 0") -TETHERKIT_MESSAGE(kUsbUnknownSpeed, "未知速度", "unknown speed") -TETHERKIT_MESSAGE(kUsbClearHaltFailed, "清除端点 0x{:02x} 的 halt 状态失败", - "failed to clear the halt condition on endpoint 0x{:02x}") +TETHERKIT_MESSAGE(kUsbBulkMaxPacketSizeZero, "the bulk endpoint has wMaxPacketSize 0") +TETHERKIT_MESSAGE(kUsbUnknownSpeed, "unknown speed") +TETHERKIT_MESSAGE(kUsbClearHaltFailed, "failed to clear the halt condition on endpoint 0x{:02x}") -// --- 中断通知 --- -TETHERKIT_MESSAGE(kUsbNoInterruptEndpoint, "设备没有中断端点,通知监听跳过(将退化为轮询控制端点)", - "the device has no interrupt endpoint; skipping the notification listener and " +// --- interrupt notification --- +TETHERKIT_MESSAGE(kUsbNoInterruptEndpoint, "the device has no interrupt endpoint; skipping the notification listener and " "falling back to polling the control endpoint") -TETHERKIT_MESSAGE(kUsbNotificationAlreadyRunning, "通知监听已启动", - "the notification listener is already running") -TETHERKIT_MESSAGE(kUsbAllocInterruptTransferFailed, "为中断通知分配 libusb transfer 失败", - "failed to allocate a libusb transfer for interrupt notifications") -TETHERKIT_MESSAGE(kUsbSubmitInterruptFailed, "提交中断通知传输失败", - "failed to submit the interrupt notification transfer") -TETHERKIT_MESSAGE(kUsbNotificationStarted, "中断通知监听已启动(端点 0x{:02x})", - "interrupt notification listener started (endpoint 0x{:02x})") -TETHERKIT_MESSAGE(kUsbCancelInterruptReturned, "取消中断通知传输返回 {}", - "cancelling the interrupt notification transfer returned {}") -TETHERKIT_MESSAGE(kUsbInterruptReclaimTimeout, - "等待中断通知传输回收超时,为避免 use-after-free 不释放它", - "timed out waiting for the interrupt notification transfer to be reclaimed; " +TETHERKIT_MESSAGE(kUsbNotificationAlreadyRunning, "the notification listener is already running") +TETHERKIT_MESSAGE(kUsbAllocInterruptTransferFailed, "failed to allocate a libusb transfer for interrupt notifications") +TETHERKIT_MESSAGE(kUsbSubmitInterruptFailed, "failed to submit the interrupt notification transfer") +TETHERKIT_MESSAGE(kUsbNotificationStarted, "interrupt notification listener started (endpoint 0x{:02x})") +TETHERKIT_MESSAGE(kUsbCancelInterruptReturned, "cancelling the interrupt notification transfer returned {}") +TETHERKIT_MESSAGE(kUsbInterruptReclaimTimeout, "timed out waiting for the interrupt notification transfer to be reclaimed; " "leaving it allocated to avoid a use-after-free") -TETHERKIT_MESSAGE(kUsbUnknownNotification, "中断端点收到未知通知 {:#010x},忽略", - "the interrupt endpoint received an unknown notification {:#010x}; ignoring it") -TETHERKIT_MESSAGE(kUsbInterruptHaltClearFailed, "清除中断端点 halt 失败,改为轮询控制端点:{}", - "failed to clear the halt on the interrupt endpoint; falling back to polling the " +TETHERKIT_MESSAGE(kUsbUnknownNotification, "the interrupt endpoint received an unknown notification {:#010x}; ignoring it") +TETHERKIT_MESSAGE(kUsbInterruptHaltClearFailed, "failed to clear the halt on the interrupt endpoint; falling back to polling the " "control endpoint: {}") -TETHERKIT_MESSAGE(kUsbInterruptTransferStatus, "中断通知传输 status={},继续监听", - "interrupt notification transfer status={}; continuing to listen") +TETHERKIT_MESSAGE(kUsbInterruptTransferStatus, "interrupt notification transfer status={}; continuing to listen") -// --- 控制通道 --- -TETHERKIT_MESSAGE(kUsbControlMessageLengthInvalid, "控制消息长度 {} 非法", - "invalid control message length {}") -TETHERKIT_MESSAGE(kUsbSendEncapsulatedFailed, "SEND_ENCAPSULATED_COMMAND 失败", - "SEND_ENCAPSULATED_COMMAND failed") -TETHERKIT_MESSAGE(kUsbSendEncapsulatedShort, "SEND_ENCAPSULATED_COMMAND 只发出 {} / {} 字节", - "SEND_ENCAPSULATED_COMMAND only sent {} of {} bytes") -TETHERKIT_MESSAGE(kUsbGetEncapsulatedFailed, "GET_ENCAPSULATED_RESPONSE 失败", - "GET_ENCAPSULATED_RESPONSE failed") +// --- control channel --- +TETHERKIT_MESSAGE(kUsbControlMessageLengthInvalid, "invalid control message length {}") +TETHERKIT_MESSAGE(kUsbSendEncapsulatedFailed, "SEND_ENCAPSULATED_COMMAND failed") +TETHERKIT_MESSAGE(kUsbSendEncapsulatedShort, "SEND_ENCAPSULATED_COMMAND only sent {} of {} bytes") +TETHERKIT_MESSAGE(kUsbGetEncapsulatedFailed, "GET_ENCAPSULATED_RESPONSE failed") -// --- 数据通道 --- -TETHERKIT_MESSAGE(kUsbTxBufferTooSmall, "TX 传输缓冲 {} 字节装不下一个满帧(需要 {} 字节)", - "the {}-byte TX transfer buffer cannot hold a full frame ({} bytes needed)") -TETHERKIT_MESSAGE(kUsbTxSlotQueueInitFailed, "初始化 TX 空闲槽位队列失败", - "failed to initialize the TX free-slot queue") -TETHERKIT_MESSAGE(kUsbDataChannelReady, - "数据通道就绪:RX {} × {} KiB(在飞 {} KiB),TX {} × {} KiB;" - "设备聚合上限 {} 字节 / {} 包,对齐 {} 字节", - "data channel ready: RX {} x {} KiB ({} KiB in flight), TX {} x {} KiB; device " +// --- data channel --- +TETHERKIT_MESSAGE(kUsbTxBufferTooSmall, "the {}-byte TX transfer buffer cannot hold a full frame ({} bytes needed)") +TETHERKIT_MESSAGE(kUsbTxSlotQueueInitFailed, "failed to initialize the TX free-slot queue") +TETHERKIT_MESSAGE(kUsbDataChannelReady, "data channel ready: RX {} x {} KiB ({} KiB in flight), TX {} x {} KiB; device " "aggregation limit {} bytes / {} packets, alignment {} bytes") -TETHERKIT_MESSAGE(kUsbTransferAllocFailed, "为 USB 传输分配 {} 字节({} 字节对齐)失败", - "failed to allocate {} bytes ({}-byte aligned) for a USB transfer") -TETHERKIT_MESSAGE(kUsbAllocTransferFailed, "libusb_alloc_transfer 失败", - "libusb_alloc_transfer failed") -TETHERKIT_MESSAGE(kUsbBulkInSubmitted, "已提交 {} 个 bulk IN 传输", "submitted {} bulk IN transfers") -TETHERKIT_MESSAGE(kUsbSubmitBulkInFailed, "提交 bulk IN 传输失败", - "failed to submit a bulk IN transfer") -TETHERKIT_MESSAGE(kUsbMalformedRndisMessage, "bulk IN 传输里的 RNDIS 消息畸形:{}(已解出 {} 帧)", - "malformed RNDIS message in a bulk IN transfer: {} ({} frames decoded so far)") -TETHERKIT_MESSAGE(kUsbBulkInStall, "bulk IN 端点 STALL,尝试清除 halt 后继续", - "bulk IN endpoint STALL; clearing the halt and continuing") -TETHERKIT_MESSAGE(kUsbBulkInClearHaltFailed, "清除 bulk IN 的 halt 失败:{}", - "failed to clear the halt on bulk IN: {}") -TETHERKIT_MESSAGE(kUsbBulkInDeviceGone, "bulk IN 传输报告设备已断开", - "a bulk IN transfer reported that the device is disconnected") -TETHERKIT_MESSAGE(kUsbBulkInFailed, "bulk IN 传输失败:status={},继续重试", - "bulk IN transfer failed: status={}; retrying") -TETHERKIT_MESSAGE(kUsbBulkInResubmitFailed, "重新提交 bulk IN 传输失败:{}", - "failed to resubmit a bulk IN transfer: {}") -TETHERKIT_MESSAGE(kUsbDataChannelShuttingDown, "数据通道正在停机,拒绝发送", - "the data channel is shutting down; refusing to send") -TETHERKIT_MESSAGE(kUsbReturnTxSlotFailed, "归还 TX 槽位 {} 失败(这不应该发生)", - "failed to return TX slot {} (this should not happen)") -TETHERKIT_MESSAGE(kUsbSubmitBulkOutFailed, "提交 bulk OUT 传输失败", - "failed to submit a bulk OUT transfer") -TETHERKIT_MESSAGE(kUsbBulkOutSubmitted, "bulk OUT 提交 {} 帧 / {} 字节净荷 / {} 字节传输", - "bulk OUT submitted {} frames / {} payload bytes / {} transfer bytes") -TETHERKIT_MESSAGE(kUsbBulkOutStall, "bulk OUT 端点 STALL,尝试清除 halt", - "bulk OUT endpoint STALL; clearing the halt") -TETHERKIT_MESSAGE(kUsbBulkOutClearHaltFailed, "清除 bulk OUT 的 halt 失败:{}", - "failed to clear the halt on bulk OUT: {}") -TETHERKIT_MESSAGE(kUsbBulkOutFailed, "bulk OUT 传输失败:status={}", - "bulk OUT transfer failed: status={}") -TETHERKIT_MESSAGE(kUsbCancelBulkInReturned, "取消 bulk IN 传输返回 {}", - "cancelling a bulk IN transfer returned {}") -TETHERKIT_MESSAGE(kUsbCancelBulkOutReturned, "取消 bulk OUT 传输返回 {}", - "cancelling a bulk OUT transfer returned {}") -TETHERKIT_MESSAGE(kUsbTransferReclaimTimeout, - "等待 {} 个在飞 USB 传输回收超时。为避免 use-after-free," - "刻意不释放这些传输的内存(泄漏约 {} KiB)。这通常意味着设备在异常状态," - "或 libusb 事件线程已经停止。", - "timed out waiting for {} in-flight USB transfers to be reclaimed. Their memory " +TETHERKIT_MESSAGE(kUsbTransferAllocFailed, "failed to allocate {} bytes ({}-byte aligned) for a USB transfer") +TETHERKIT_MESSAGE(kUsbAllocTransferFailed, "libusb_alloc_transfer failed") +TETHERKIT_MESSAGE(kUsbBulkInSubmitted, "submitted {} bulk IN transfers") +TETHERKIT_MESSAGE(kUsbSubmitBulkInFailed, "failed to submit a bulk IN transfer") +TETHERKIT_MESSAGE(kUsbMalformedRndisMessage, "malformed RNDIS message in a bulk IN transfer: {} ({} frames decoded so far)") +TETHERKIT_MESSAGE(kUsbBulkInStall, "bulk IN endpoint STALL; clearing the halt and continuing") +TETHERKIT_MESSAGE(kUsbBulkInClearHaltFailed, "failed to clear the halt on bulk IN: {}") +TETHERKIT_MESSAGE(kUsbBulkInDeviceGone, "a bulk IN transfer reported that the device is disconnected") +TETHERKIT_MESSAGE(kUsbBulkInFailed, "bulk IN transfer failed: status={}; retrying") +TETHERKIT_MESSAGE(kUsbBulkInResubmitFailed, "failed to resubmit a bulk IN transfer: {}") +TETHERKIT_MESSAGE(kUsbDataChannelShuttingDown, "the data channel is shutting down; refusing to send") +TETHERKIT_MESSAGE(kUsbReturnTxSlotFailed, "failed to return TX slot {} (this should not happen)") +TETHERKIT_MESSAGE(kUsbSubmitBulkOutFailed, "failed to submit a bulk OUT transfer") +TETHERKIT_MESSAGE(kUsbBulkOutSubmitted, "bulk OUT submitted {} frames / {} payload bytes / {} transfer bytes") +TETHERKIT_MESSAGE(kUsbBulkOutStall, "bulk OUT endpoint STALL; clearing the halt") +TETHERKIT_MESSAGE(kUsbBulkOutClearHaltFailed, "failed to clear the halt on bulk OUT: {}") +TETHERKIT_MESSAGE(kUsbBulkOutFailed, "bulk OUT transfer failed: status={}") +TETHERKIT_MESSAGE(kUsbCancelBulkInReturned, "cancelling a bulk IN transfer returned {}") +TETHERKIT_MESSAGE(kUsbCancelBulkOutReturned, "cancelling a bulk OUT transfer returned {}") +TETHERKIT_MESSAGE(kUsbTransferReclaimTimeout, "timed out waiting for {} in-flight USB transfers to be reclaimed. Their memory " "is deliberately not freed to avoid a use-after-free (leaking about {} KiB). " "This usually means the device is in a bad state, or the libusb event thread has " "stopped.") -TETHERKIT_MESSAGE(kUsbDataChannelStopped, "数据通道已停机", "data channel stopped") +TETHERKIT_MESSAGE(kUsbDataChannelStopped, "data channel stopped") // ============================================================================= -// core —— 数据路径桥接与运行时编排 +// core -- data-path bridge and runtime orchestration // ============================================================================= -// --- 桥接层 --- -TETHERKIT_MESSAGE(kCoreBridgeAlreadyRunning, "桥接层已在运行", "the bridge is already running") -TETHERKIT_MESSAGE(kCoreDataPathStarted, - "数据路径已启动:RX 队列 {} 帧({} KiB),RX 写批 {} 帧,TX 提交批 {} 帧," - "链路批量写 {}", - "data path started: RX queue {} frames ({} KiB), RX write batch {} frames, TX " +// --- bridge --- +TETHERKIT_MESSAGE(kCoreBridgeAlreadyRunning, "the bridge is already running") +TETHERKIT_MESSAGE(kCoreDataPathStarted, "data path started: RX queue {} frames ({} KiB), RX write batch {} frames, TX " "submit batch {} frames, link batch write {}") -TETHERKIT_MESSAGE(kCoreBatchWriteAvailable, "可用", "available") -TETHERKIT_MESSAGE(kCoreBatchWriteUnavailable, "不可用(逐帧写)", - "unavailable (writing one frame at a time)") -TETHERKIT_MESSAGE(kCoreDataPathStopped, "数据路径已停止", "data path stopped") -TETHERKIT_MESSAGE(kCoreRxInjectorStarted, "RX 注入线程已启动", "RX injector thread started") -TETHERKIT_MESSAGE(kCoreRxInjectorExited, "RX 注入线程已退出", "RX injector thread exited") -TETHERKIT_MESSAGE(kCoreLinkWriteFailed, "向链路写入 {} 帧失败:{}", - "failed to write {} frames to the link: {}") -TETHERKIT_MESSAGE(kCoreTxExtractorStarted, "TX 抽取线程已启动", "TX extractor thread started") -TETHERKIT_MESSAGE(kCoreTxExtractorExited, "TX 抽取线程已退出", "TX extractor thread exited") -TETHERKIT_MESSAGE(kCoreLinkReadFailed, "从链路读取失败:{}", "failed to read from the link: {}") -TETHERKIT_MESSAGE(kCoreUsbSubmitFailed, "向 USB 提交 {} 帧失败:{}", - "failed to submit {} frames to USB: {}") -TETHERKIT_MESSAGE(kCoreStatsLine, - "RX {:>8.0f} pps / {:>8.2f} Mbps(丢 {}) | " - "TX {:>8.0f} pps / {:>8.2f} Mbps(丢 {}) | " - "队列深度 {} 内核丢包 {} 背压 {}", - "RX {:>8.0f} pps / {:>8.2f} Mbps (dropped {}) | " +TETHERKIT_MESSAGE(kCoreBatchWriteAvailable, "available") +TETHERKIT_MESSAGE(kCoreBatchWriteUnavailable, "unavailable (writing one frame at a time)") +TETHERKIT_MESSAGE(kCoreDataPathStopped, "data path stopped") +TETHERKIT_MESSAGE(kCoreRxInjectorStarted, "RX injector thread started") +TETHERKIT_MESSAGE(kCoreRxInjectorExited, "RX injector thread exited") +TETHERKIT_MESSAGE(kCoreLinkWriteFailed, "failed to write {} frames to the link: {}") +TETHERKIT_MESSAGE(kCoreTxExtractorStarted, "TX extractor thread started") +TETHERKIT_MESSAGE(kCoreTxExtractorExited, "TX extractor thread exited") +TETHERKIT_MESSAGE(kCoreLinkReadFailed, "failed to read from the link: {}") +TETHERKIT_MESSAGE(kCoreUsbSubmitFailed, "failed to submit {} frames to USB: {}") +TETHERKIT_MESSAGE(kCoreStatsLine, "RX {:>8.0f} pps / {:>8.2f} Mbps (dropped {}) | " "TX {:>8.0f} pps / {:>8.2f} Mbps (dropped {}) | " "queue depth {} kernel drops {} backpressure {}") -// --- 运行时状态名 --- -TETHERKIT_MESSAGE(kCoreRunStateIdle, "空闲", "idle") -TETHERKIT_MESSAGE(kCoreRunStateStarting, "启动中", "starting") -TETHERKIT_MESSAGE(kCoreRunStateRunning, "运行中", "running") -TETHERKIT_MESSAGE(kCoreRunStateStopping, "停机中", "stopping") -TETHERKIT_MESSAGE(kCoreRunStateStopped, "已停机", "stopped") -TETHERKIT_MESSAGE(kCoreRunStateFailed, "已失败", "failed") -TETHERKIT_MESSAGE(kCoreRunStateUnknown, "未知", "unknown") +// --- runtime state names --- +TETHERKIT_MESSAGE(kCoreRunStateIdle, "idle") +TETHERKIT_MESSAGE(kCoreRunStateStarting, "starting") +TETHERKIT_MESSAGE(kCoreRunStateRunning, "running") +TETHERKIT_MESSAGE(kCoreRunStateStopping, "stopping") +TETHERKIT_MESSAGE(kCoreRunStateStopped, "stopped") +TETHERKIT_MESSAGE(kCoreRunStateFailed, "failed") +TETHERKIT_MESSAGE(kCoreRunStateUnknown, "unknown") -// --- 运行时编排 --- -TETHERKIT_MESSAGE(kCoreRuntimeAlreadyStarted, "运行时已启动", "the runtime is already started") -TETHERKIT_MESSAGE(kCoreNeedsRoot, - "TetherKit 需要 root 权限才能创建 feth 虚拟网卡并打开 /dev/bpf*。\n" - " 请用 sudo 运行:sudo tetherkit-cli\n" - " (USB 侧本身不需要 root —— macOS 没有 RNDIS 内核驱动," - "所以 libusb 能直接声明接口。)", - "TetherKit needs root to create the feth virtual interface and open /dev/bpf*.\n" +// --- runtime orchestration --- +TETHERKIT_MESSAGE(kCoreRuntimeAlreadyStarted, "the runtime is already started") +TETHERKIT_MESSAGE(kCoreNeedsRoot, "TetherKit needs root to create the feth virtual interface and open /dev/bpf*.\n" " Run it with sudo: sudo tetherkit-cli\n" " (The USB side itself does not need root -- macOS has no RNDIS kernel driver, " "so libusb can claim the interface directly.)") -TETHERKIT_MESSAGE(kCoreNoDeviceFound, - "没有找到 RNDIS 设备。请检查:\n" - " 1. 设备已用 USB 数据线(不是只供电的线)连接;\n" - " 2. 设备上已开启「USB 网络共享 / USB tethering」;\n" - " 3. 手机解锁并信任本机(部分设备锁屏时不暴露 RNDIS 接口)。\n" - " 可用 `system_profiler SPUSBDataType` 确认设备是否被系统识别。", - "No RNDIS device found. Check that:\n" +TETHERKIT_MESSAGE(kCoreNoDeviceFound, "No RNDIS device found. Check that:\n" " 1. the device is connected with a USB data cable (not a charge-only one);\n" " 2. \"USB tethering\" is enabled on the device;\n" " 3. the phone is unlocked and trusts this Mac (some devices hide the RNDIS " "interface while locked).\n" " Run `system_profiler SPUSBDataType` to confirm the system sees the device.") -TETHERKIT_MESSAGE(kCoreMultipleDevices, "找到 {} 个 RNDIS 设备,使用第一个(可按 VID/PID 指定)", - "found {} RNDIS devices; using the first one (use VID/PID to pick another)") -TETHERKIT_MESSAGE(kCoreExitingOnFatal, "因不可恢复的错误退出", - "exiting because of an unrecoverable error") -TETHERKIT_MESSAGE(kCoreInterfaceReady, "==== 网卡已就绪:{} ====", "==== interface ready: {} ====") -TETHERKIT_MESSAGE(kCoreNextStepsAssignIp, "接下来给它配一个 IP(RNDIS 设备通常自带 DHCP 服务器):", - "Next, give it an IP address (RNDIS devices usually run their own DHCP server):") -TETHERKIT_MESSAGE(kCoreNextStepsVerify, "验证:", "Verify with:") -TETHERKIT_MESSAGE(kCoreNextStepsDefaultRoute, "若要让流量默认走它(会顶掉现有默认路由,请谨慎):", - "To route traffic through it by default (this replaces the current default " +TETHERKIT_MESSAGE(kCoreMultipleDevices, "found {} RNDIS devices; using the first one (use VID/PID to pick another)") +TETHERKIT_MESSAGE(kCoreExitingOnFatal, "exiting because of an unrecoverable error") +TETHERKIT_MESSAGE(kCoreInterfaceReady, "==== interface ready: {} ====") +TETHERKIT_MESSAGE(kCoreNextStepsAssignIp, "Next, give it an IP address (RNDIS devices usually run their own DHCP server):") +TETHERKIT_MESSAGE(kCoreNextStepsVerify, "Verify with:") +TETHERKIT_MESSAGE(kCoreNextStepsDefaultRoute, "To route traffic through it by default (this replaces the current default " "route -- be careful):") -TETHERKIT_MESSAGE(kCoreNextStepsTemporaryNote1, - "注意:ipconfig set 建立的是**临时**服务,只存活到下一次网络", - "Note: ipconfig set creates a *temporary* service that only lives until the") -TETHERKIT_MESSAGE(kCoreNextStepsTemporaryNote2, - " 配置变更,且不会出现在「系统设置 → 网络」里。", - " next network configuration change, and never appears in System Settings > " +TETHERKIT_MESSAGE(kCoreNextStepsTemporaryNote1, "Note: ipconfig set creates a *temporary* service that only lives until the") +TETHERKIT_MESSAGE(kCoreNextStepsTemporaryNote2, " next network configuration change, and never appears in System Settings > " "Network.") -TETHERKIT_MESSAGE(kCoreStopping, "正在停机……", "stopping...") -TETHERKIT_MESSAGE(kCoreStopped, "已停机", "stopped") -TETHERKIT_MESSAGE(kCoreRndisReady, "RNDIS 就绪:设备 MAC {},MTU {},链路 {:.0f} Mbps", - "RNDIS ready: device MAC {}, MTU {}, link {:.0f} Mbps") -TETHERKIT_MESSAGE(kCoreLinkState, "链路状态:{}", "link state: {}") -TETHERKIT_MESSAGE(kCoreLinkConnected, "已连接", "connected") -TETHERKIT_MESSAGE(kCoreLinkDisconnected, "已断开", "disconnected") -TETHERKIT_MESSAGE(kCoreDeviceReset, "设备已软复位(寻址信息{})", - "the device performed a soft reset (addressing information {})") -TETHERKIT_MESSAGE(kCoreAddressingLost, "丢失,已重放", "was lost and has been replayed") -TETHERKIT_MESSAGE(kCoreAddressingKept, "保持", "was preserved") -TETHERKIT_MESSAGE(kCoreLinkUnrecoverable, "链路不可恢复", "the link is unrecoverable") +TETHERKIT_MESSAGE(kCoreStopping, "stopping...") +TETHERKIT_MESSAGE(kCoreStopped, "stopped") +TETHERKIT_MESSAGE(kCoreRndisReady, "RNDIS ready: device MAC {}, MTU {}, link {:.0f} Mbps") +TETHERKIT_MESSAGE(kCoreLinkState, "link state: {}") +TETHERKIT_MESSAGE(kCoreLinkConnected, "connected") +TETHERKIT_MESSAGE(kCoreLinkDisconnected, "disconnected") +TETHERKIT_MESSAGE(kCoreDeviceReset, "the device performed a soft reset (addressing information {})") +TETHERKIT_MESSAGE(kCoreAddressingLost, "was lost and has been replayed") +TETHERKIT_MESSAGE(kCoreAddressingKept, "was preserved") +TETHERKIT_MESSAGE(kCoreLinkUnrecoverable, "the link is unrecoverable") // ============================================================================= -// capi —— C ABI 层(网卡配置、会话、孤儿清理、子进程) +// capi -- C ABI layer (interface configuration, sessions, orphan cleanup, subprocesses) // ============================================================================= -// --- 子进程 --- -TETHERKIT_MESSAGE(kCapiPipeFailed, "创建管道失败", "failed to create a pipe") -TETHERKIT_MESSAGE(kCapiSpawnFileActionsFailed, "posix_spawn_file_actions_init 失败", - "posix_spawn_file_actions_init failed") -TETHERKIT_MESSAGE(kCapiExecFailed, "无法执行 {}", "cannot execute {}") -TETHERKIT_MESSAGE(kCapiWaitFailed, "等待 {} 结束失败", "failed to wait for {} to finish") +// --- subprocess --- +TETHERKIT_MESSAGE(kCapiPipeFailed, "failed to create a pipe") +TETHERKIT_MESSAGE(kCapiSpawnFileActionsFailed, "posix_spawn_file_actions_init failed") +TETHERKIT_MESSAGE(kCapiExecFailed, "cannot execute {}") +TETHERKIT_MESSAGE(kCapiWaitFailed, "failed to wait for {} to finish") -// --- 会话 --- -TETHERKIT_MESSAGE(kCapiSessionConfigNull, "tk_session_create:config 不能为空", - "tk_session_create: config must not be null") -TETHERKIT_MESSAGE(kCapiSessionOutOfMemory, "内存不足,无法创建会话", - "out of memory; cannot create the session") +// --- sessions --- +TETHERKIT_MESSAGE(kCapiSessionConfigNull, "tk_session_create: config must not be null") +TETHERKIT_MESSAGE(kCapiSessionOutOfMemory, "out of memory; cannot create the session") -// --- 孤儿网卡清理 --- -TETHERKIT_MESSAGE(kCapiCleanupNeedsRoot, "清理残留虚拟网卡需要 root 权限", - "cleaning up leftover virtual interfaces requires root") -TETHERKIT_MESSAGE(kCapiOrphanAlreadyGone, "残留网卡 {} 已不存在或无法销毁", - "leftover interface {} is already gone, or cannot be destroyed") +// --- orphaned interface cleanup --- +TETHERKIT_MESSAGE(kCapiCleanupNeedsRoot, "cleaning up leftover virtual interfaces requires root") +TETHERKIT_MESSAGE(kCapiOrphanAlreadyGone, "leftover interface {} is already gone, or cannot be destroyed") -// --- 网卡 IP 配置 --- -TETHERKIT_MESSAGE(kCapiInterfaceNameNull, "网卡名不能为空", "the interface name must not be empty") -TETHERKIT_MESSAGE(kCapiInterfaceNotOurs, - "拒绝操作网卡 \"{}\":只接受 TetherKit 自己创建的 feth<数字> 接口。\n" - " 这条限制是为了防止误操作把真实网卡(en0 等)的配置冲掉。", - "refusing to touch interface \"{}\": only feth interfaces created by " +// --- interface IP configuration --- +TETHERKIT_MESSAGE(kCapiInterfaceNameNull, "the interface name must not be empty") +TETHERKIT_MESSAGE(kCapiInterfaceNotOurs, "refusing to touch interface \"{}\": only feth interfaces created by " "TetherKit are accepted.\n" " This guard exists so a mistake cannot wipe the configuration of a real " "interface such as en0.") -TETHERKIT_MESSAGE(kCapiCommandFailed, "{} 失败(退出码 {}):{}", "{} failed (exit code {}): {}") -TETHERKIT_MESSAGE(kCapiCommandNoOutput, "命令没有输出任何原因", - "the command printed no explanation") -TETHERKIT_MESSAGE(kCapiDnsPublishFailed, "写入 DNS 键失败(服务 {}),静态模式下 DNS 可能不生效", - "failed to write the DNS key (service {}); DNS may not take effect in manual " +TETHERKIT_MESSAGE(kCapiCommandFailed, "{} failed (exit code {}): {}") +TETHERKIT_MESSAGE(kCapiCommandNoOutput, "the command printed no explanation") +TETHERKIT_MESSAGE(kCapiDnsPublishFailed, "failed to write the DNS key (service {}); DNS may not take effect in manual " "mode") -TETHERKIT_MESSAGE(kCapiWhatAddScopedRoute, "添加 scoped 默认路由", "adding a scoped default route") -TETHERKIT_MESSAGE(kCapiWhatUpdateScopedRoute, "更新 scoped 默认路由", - "updating the scoped default route") -TETHERKIT_MESSAGE(kCapiWhatSwitchGlobalRoute, "切换全局默认路由", - "switching the global default route") -TETHERKIT_MESSAGE(kCapiWhatAddGlobalRoute, "添加全局默认路由", "adding a global default route") -TETHERKIT_MESSAGE(kCapiWhatStartDhcp, "启动 DHCP", "starting DHCP") -TETHERKIT_MESSAGE(kCapiDhcpTimeout, - "DHCP 在 {} 秒内没有拿到租约。\n" - " 常见原因:设备侧没有开启网络共享,或对端没有 DHCP 服务器。\n" - " 服务仍在后台重试,稍后刷新可能会看到地址;也可以改用静态 IP。", - "DHCP did not obtain a lease within {} seconds.\n" +TETHERKIT_MESSAGE(kCapiWhatAddScopedRoute, "adding a scoped default route") +TETHERKIT_MESSAGE(kCapiWhatUpdateScopedRoute, "updating the scoped default route") +TETHERKIT_MESSAGE(kCapiWhatSwitchGlobalRoute, "switching the global default route") +TETHERKIT_MESSAGE(kCapiWhatAddGlobalRoute, "adding a global default route") +TETHERKIT_MESSAGE(kCapiWhatStartDhcp, "starting DHCP") +TETHERKIT_MESSAGE(kCapiDhcpTimeout, "DHCP did not obtain a lease within {} seconds.\n" " Common causes: tethering is not enabled on the device, or the peer runs no " "DHCP server.\n" " The service keeps retrying in the background, so an address may still appear " "if you refresh later; you can also switch to a static IP.") -TETHERKIT_MESSAGE(kCapiDhcpNoRouter, - "已拿到 DHCP 租约,但租约里没有网关地址,无法把全局默认路由指向它。", - "got a DHCP lease, but it carries no router address, so the global default route " +TETHERKIT_MESSAGE(kCapiDhcpNoRouter, "got a DHCP lease, but it carries no router address, so the global default route " "cannot be pointed at it.") -TETHERKIT_MESSAGE(kCapiInvalidAddress, "IP 地址 \"{}\" 不是合法的 IPv4 地址", - "\"{}\" is not a valid IPv4 address") -TETHERKIT_MESSAGE(kCapiInvalidNetmask, "子网掩码 \"{}\" 不是合法的 IPv4 地址", - "subnet mask \"{}\" is not a valid IPv4 address") -TETHERKIT_MESSAGE(kCapiInvalidRouter, "网关 \"{}\" 不是合法的 IPv4 地址", - "router \"{}\" is not a valid IPv4 address") -TETHERKIT_MESSAGE(kCapiDefaultRouteNeedsRouter, "勾选了「设为默认路由」就必须填写网关地址", - "\"Use as default route\" requires a router address") -TETHERKIT_MESSAGE(kCapiInvalidDns, "DNS 服务器 \"{}\" 不是合法的 IPv4 地址", - "DNS server \"{}\" is not a valid IPv4 address") -TETHERKIT_MESSAGE(kCapiWhatApplyManual, "配置静态 IP", "applying a static IP") -TETHERKIT_MESSAGE(kCapiNoServiceForDns, "找不到 {} 对应的网络服务,DNS 无法下发", - "no network service found for {}; DNS cannot be published") -TETHERKIT_MESSAGE(kCapiWhatClearConfig, "撤销网卡配置", "clearing the interface configuration") -TETHERKIT_MESSAGE(kCapiApplyConfigNull, "tk_net_apply:config 不能为空", - "tk_net_apply: config must not be null") -TETHERKIT_MESSAGE(kCapiApplyNeedsRoot, "配置网卡 IP 需要 root 权限", - "configuring an interface IP requires root") -TETHERKIT_MESSAGE(kCapiUnknownIpMode, "未知的上网方式 {}", "unknown IP mode {}") -TETHERKIT_MESSAGE(kCapiClearNeedsRoot, "撤销网卡配置需要 root 权限", - "clearing an interface configuration requires root") +TETHERKIT_MESSAGE(kCapiInvalidAddress, "\"{}\" is not a valid IPv4 address") +TETHERKIT_MESSAGE(kCapiInvalidNetmask, "subnet mask \"{}\" is not a valid IPv4 address") +TETHERKIT_MESSAGE(kCapiInvalidRouter, "router \"{}\" is not a valid IPv4 address") +TETHERKIT_MESSAGE(kCapiDefaultRouteNeedsRouter, "\"Use as default route\" requires a router address") +TETHERKIT_MESSAGE(kCapiInvalidDns, "DNS server \"{}\" is not a valid IPv4 address") +TETHERKIT_MESSAGE(kCapiWhatApplyManual, "applying a static IP") +TETHERKIT_MESSAGE(kCapiNoServiceForDns, "no network service found for {}; DNS cannot be published") +TETHERKIT_MESSAGE(kCapiWhatClearConfig, "clearing the interface configuration") +TETHERKIT_MESSAGE(kCapiApplyConfigNull, "tk_net_apply: config must not be null") +TETHERKIT_MESSAGE(kCapiApplyNeedsRoot, "configuring an interface IP requires root") +TETHERKIT_MESSAGE(kCapiUnknownIpMode, "unknown IP mode {}") +TETHERKIT_MESSAGE(kCapiClearNeedsRoot, "clearing an interface configuration requires root") // ============================================================================= -// cli —— 命令行 tetherkit-cli +// cli -- the tetherkit-cli command line // ============================================================================= -// 帮助文本整条一句话地放在这里而不是逐行拆开:它要对齐排版,拆成几十条小文案 -// 反而没法保证译文的列宽一致。 -TETHERKIT_MESSAGE( - kCliUsage, - "用法:sudo tetherkit-cli [选项]\n" - "\n" - " macOS 用户态 RNDIS 驱动:把 RNDIS 设备(Android USB 网络共享等)\n" - " 变成一张系统可见的网卡。\n" - "\n" - "设备选择:\n" - " --vid <十六进制> 只匹配指定 USB 厂商 ID(如 18d1)\n" - " --pid <十六进制> 只匹配指定 USB 产品 ID(如 4ee4)\n" - " --list 列出识别到的 RNDIS 设备后退出(**不需要 root**)\n" - "\n" - "网卡:\n" - " --mtu <字节> 期望 MTU,默认 1500(设备装不下时会自动下调;\n" - " 上限受 sysctl net.link.fake.max_mtu 约束)\n" - " --keep-feth-mac 不把系统侧网卡的 MAC 改成设备汇报的地址\n" - "\n" - "性能调优:\n" - " --rx-transfers 并发在飞的 bulk IN 传输数,默认 16\n" - " --tx-transfers 并发在飞的 bulk OUT 传输数,默认 4。实测 4/8/16\n" - " 无可分辨差异(池占满时会等槽位而不是丢帧),\n" - " 一般不需要动它\n" - " --rx-transfer-kb 每个 bulk IN 缓冲的 KiB 数,默认 16\n" - " --max-transfer-kb 在 INITIALIZE 里宣称的 MaxTransferSize(KiB),\n" - " 默认 16。这是让设备聚合多包的唯一手段,是吞吐的\n" - " 主要杠杆;报大一点通常更快\n" - " --max-tx-packets host → device 每次传输最多聚合几个 RNDIS 包,\n" - " 0(默认)表示听设备汇报的 MaxPacketsPerMessage。\n" - " 诊断用:怀疑设备汇报了聚合能力却只拆第一个包时,\n" - " 设成 1 对比一下。注意实测过的 Android 设备聚合是\n" - " 有效的,钳到 1 反而更慢,别当成丢帧的默认解法\n" - " --bpf-buffer-kb BPF 内核抓包缓冲的 KiB 数,默认 4096\n" - "\n" - "其它:\n" - " --stats <毫秒> 统计报告周期,0 表示关闭,默认 5000\n" - " --log <级别> trace|debug|info|warn|error|off,默认 info\n" - " --lang <语言> 界面语言:zh|en|auto,默认 auto —— 依次看\n" - " TETHERKIT_LANG、LC_ALL、LC_MESSAGES、LANG,\n" - " 取第一个非空值,以 zh 开头算中文,其余算英文。\n" - " 注意 sudo 未必透传这些变量,那时就显式写 --lang\n" - " --no-color 关闭日志彩色输出\n" - " -h, --help 显示本帮助\n" - " -V, --version 显示版本\n" - "\n" - "为什么需要 root:创建 feth 虚拟网卡与打开 /dev/bpf* 都需要 root。\n" - "USB 侧本身不需要 —— macOS 没有 RNDIS 内核驱动,libusb 能直接声明接口。\n", - "Usage: sudo tetherkit-cli [options]\n" +// The CLI help text lives here as whole sentences rather than split into tiny pieces: +// the text must align as fixed layout, and dozens of tiny strings cannot keep the widths consistent. +TETHERKIT_MESSAGE(kCliUsage, "Usage: sudo tetherkit-cli [options]\n" "\n" " A user-space RNDIS driver for macOS: turns an RNDIS device (Android USB\n" " tethering and friends) into an interface the system can see.\n" @@ -801,11 +513,12 @@ TETHERKIT_MESSAGE( "Other:\n" " --stats statistics reporting interval, 0 disables, default 5000\n" " --log trace|debug|info|warn|error|off, default info\n" - " --lang interface language: zh|en|auto, default auto -- checks\n" + " --lang interface language, default auto -- checks\n" " TETHERKIT_LANG, LC_ALL, LC_MESSAGES and LANG in that order\n" - " and takes the first non-empty value; anything starting with\n" - " zh means Chinese, everything else English. sudo does not\n" - " always pass those through, so pass --lang explicitly then\n" + " and takes the first non-empty value. English is the only\n" + " language shipped today; en and auto both select it. sudo\n" + " does not always pass those through, so pass --lang\n" + " explicitly then\n" " --no-color disable coloured log output\n" " -h, --help show this help\n" " -V, --version show the version\n" @@ -814,30 +527,22 @@ TETHERKIT_MESSAGE( "both need root. The USB side does not -- macOS ships no RNDIS kernel driver, so\n" "libusb can claim the interface directly.\n") -TETHERKIT_MESSAGE(kCliInstallSignalHandlerFailed, "安装信号 {} 的处理器失败", - "failed to install a handler for signal {}") -TETHERKIT_MESSAGE(kCliIgnoreSigpipeFailed, "忽略 SIGPIPE 失败", "failed to ignore SIGPIPE") -TETHERKIT_MESSAGE(kCliParseNumberFailed, "无法解析数值 \"{}\"", "cannot parse the number \"{}\"") -TETHERKIT_MESSAGE(kCliUnknownLogLevel, "未知日志级别 \"{}\"", "unknown log level \"{}\"") -TETHERKIT_MESSAGE(kCliUnknownLanguage, "未知语言 \"{}\",可用的是 zh、en、auto", - "unknown language \"{}\"; valid values are zh, en and auto") -TETHERKIT_MESSAGE(kCliMissingOptionValue, "选项 {} 缺少参数", "option {} is missing its argument") -TETHERKIT_MESSAGE(kCliUnknownOption, "未知选项 \"{}\",用 --help 查看用法", - "unknown option \"{}\"; run --help for usage") -TETHERKIT_MESSAGE(kCliArgumentError, "参数错误:{}\n", "argument error: {}\n") -TETHERKIT_MESSAGE(kCliNoDevices, "没有找到 RNDIS 设备。\n", "No RNDIS device found.\n") -TETHERKIT_MESSAGE(kCliNoDevicesHint, - "请检查:① 用的是数据线而非只供电的线;② 设备上已开启 USB 网络共享;\n" - " ③ 手机已解锁并信任本机。\n", - "Check that: (1) the cable carries data and is not charge-only; (2) USB\n" +TETHERKIT_MESSAGE(kCliInstallSignalHandlerFailed, "failed to install a handler for signal {}") +TETHERKIT_MESSAGE(kCliIgnoreSigpipeFailed, "failed to ignore SIGPIPE") +TETHERKIT_MESSAGE(kCliParseNumberFailed, "cannot parse the number \"{}\"") +TETHERKIT_MESSAGE(kCliUnknownLogLevel, "unknown log level \"{}\"") +TETHERKIT_MESSAGE(kCliUnknownLanguage, "unknown language \"{}\"; English is the only language shipped today") +TETHERKIT_MESSAGE(kCliMissingOptionValue, "option {} is missing its argument") +TETHERKIT_MESSAGE(kCliUnknownOption, "unknown option \"{}\"; run --help for usage") +TETHERKIT_MESSAGE(kCliArgumentError, "argument error: {}\n") +TETHERKIT_MESSAGE(kCliNoDevices, "No RNDIS device found.\n") +TETHERKIT_MESSAGE(kCliNoDevicesHint, "Check that: (1) the cable carries data and is not charge-only; (2) USB\n" " tethering is enabled on the device; (3) the phone is unlocked\n" " and trusts this Mac.\n") -TETHERKIT_MESSAGE(kCliDevicesHeader, "识别到的 RNDIS 设备:\n", "RNDIS devices found:\n") -TETHERKIT_MESSAGE(kCliDeviceLine, " {} 通信接口 {} / 数据接口 {} 签名 {:02x}/{:02x}/{:02x}{}\n", - " {} control interface {} / data interface {} signature " +TETHERKIT_MESSAGE(kCliDevicesHeader, "RNDIS devices found:\n") +TETHERKIT_MESSAGE(kCliDeviceLine, " {} control interface {} / data interface {} signature " "{:02x}/{:02x}/{:02x}{}\n") -TETHERKIT_MESSAGE(kCliDeviceAndroidQuirk, " (走 Android quirk 兜底)", - " (matched via the Android quirk fallback)") -TETHERKIT_MESSAGE(kCliEnumerateFailed, "枚举设备失败:{}\n", "failed to enumerate devices: {}\n") -TETHERKIT_MESSAGE(kCliStartFailed, "启动失败:{}", "failed to start: {}") -TETHERKIT_MESSAGE(kCliStopSignalReceived, "收到停机信号", "received a shutdown signal") +TETHERKIT_MESSAGE(kCliDeviceAndroidQuirk, " (matched via the Android quirk fallback)") +TETHERKIT_MESSAGE(kCliEnumerateFailed, "failed to enumerate devices: {}\n") +TETHERKIT_MESSAGE(kCliStartFailed, "failed to start: {}") +TETHERKIT_MESSAGE(kCliStopSignalReceived, "received a shutdown signal") diff --git a/src/capi/environment.cc b/src/capi/environment.cc index c3745cf..2d1b399 100644 --- a/src/capi/environment.cc +++ b/src/capi/environment.cc @@ -155,20 +155,22 @@ void FillDeviceInfo(const tetherkit::usb::DeviceCandidate& candidate, namespace { -/// tk_language_t → C++ 侧的 Language。越界返回 nullopt,让调用方忽略这次设置。 +/// tk_language_t → C++ Language. Unknown values return nullopt, which makes +/// the set call a no-op: a caller still passing a removed language id (the +/// old Chinese value 1) keeps working and simply stays in English. [[nodiscard]] std::optional ToLanguage(std::int32_t value) noexcept { switch (value) { case TK_LANGUAGE_ENGLISH: return tetherkit::Language::kEnglish; - case TK_LANGUAGE_CHINESE: - return tetherkit::Language::kChinese; default: return std::nullopt; } } -[[nodiscard]] std::int32_t FromLanguage(tetherkit::Language language) noexcept { - return language == tetherkit::Language::kChinese ? TK_LANGUAGE_CHINESE : TK_LANGUAGE_ENGLISH; +[[nodiscard]] std::int32_t FromLanguage(tetherkit::Language /*language*/) noexcept { + // English is the only language shipped today; the parameter is kept so the + // signature survives a second language unchanged. + return TK_LANGUAGE_ENGLISH; } } // namespace diff --git a/src/common/i18n.cc b/src/common/i18n.cc index 7b874e6..6b376c7 100644 --- a/src/common/i18n.cc +++ b/src/common/i18n.cc @@ -1,7 +1,8 @@ #include "tetherkit/common/i18n.h" -// ContextSeparator() 的声明在 error.h 里(那边不想拖上 ), -// 实现在本文件底部 —— 包含它才能让编译器核对声明与定义一致。 +// ContextSeparator() is declared in error.h (which avoids pulling in +// ); including it here lets the compiler check that declaration and +// definition agree. #include "tetherkit/common/error.h" #include @@ -15,37 +16,37 @@ namespace tetherkit { namespace { -/// 文案条数。两张表都按它定长,保证「加了枚举却漏了译文」在编译期就炸。 +/// Message count. The table is fixed-length, so "added an enum value but no +/// string" fails at compile time. constexpr std::size_t kMessageCount = static_cast(Msg::kMessageCount); -/// 中文表。 -constexpr std::array kChineseTable{ -#define TETHERKIT_MESSAGE(id, zh, en) zh, -#include "tetherkit/common/messages.def" // NOLINT(bugprone-suspicious-include) -#undef TETHERKIT_MESSAGE -}; - -/// 英文表。 -constexpr std::array kEnglishTable{ -#define TETHERKIT_MESSAGE(id, zh, en) en, +/// Message table. +/// +/// One column today. When a language is added: one more array right next to +/// this one, extracted from the new column, and a dispatch in TextIn() that +/// picks by Language (see the checklist in messages.def). +constexpr std::array kMessageTable{ +#define TETHERKIT_MESSAGE(id, en) en, #include "tetherkit/common/messages.def" // NOLINT(bugprone-suspicious-include) #undef TETHERKIT_MESSAGE }; -/// 当前语言。进程级单一状态,用原子而非互斥:每条文案都要读它。 +/// Current language. Process-wide single state, atomic rather than mutexed: +/// every single message lookup reads it. /// -/// 默认英文而不是中文:本表的调用点遍布库内部,宿主(命令行 / GUI)会在启动时 -/// 立刻用 SetLanguage 覆盖掉。选英文作缺省是因为「没人设置过语言」通常意味着 -/// 调用方是第三方绑定,英文比中文更可能被看懂。 +/// English stays the default: the CLI and the GUI overwrite it explicitly at +/// startup, and an unset language most likely belongs to a third-party +/// binding, for which English is more useful. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::atomic g_language{Language::kEnglish}; -/// ASCII 小写化。只用来比较语言标签,不需要考虑区域设置。 +/// ASCII lowercasing. Only used for language-tag comparison, so locale rules +/// are irrelevant. [[nodiscard]] char ToLowerAscii(char c) noexcept { return static_cast(std::tolower(static_cast(c))); } -/// `text` 是否以 `prefix` 开头(ASCII 大小写不敏感)。 +/// Case-insensitive ASCII prefix test. [[nodiscard]] bool StartsWithIgnoreCase(std::string_view text, std::string_view prefix) noexcept { if (text.size() < prefix.size()) { return false; @@ -58,10 +59,11 @@ std::atomic g_language{Language::kEnglish}; return true; } -/// 读一个环境变量,未设置或为空串时返回空 view。 +/// Reads an environment variable; empty when unset or set to "". [[nodiscard]] std::string_view ReadEnvironment(const char* name) noexcept { - // getenv 被 concurrency-mt-unsafe 标记,因为它与 setenv 并发时不安全。这里 - // 只在进程启动、还没起任何工作线程时读一次,且全项目从不调用 setenv。 + // getenv is flagged concurrency-mt-unsafe because it races with setenv. + // Here it runs once at process startup, before any worker thread exists, + // and the project never calls setenv. // NOLINTNEXTLINE(concurrency-mt-unsafe) const char* value = std::getenv(name); if (value == nullptr || *value == '\0') { @@ -80,20 +82,26 @@ Language GetLanguage() noexcept { return g_language.load(std::memory_order_relaxed); } -Language LanguageFromLocaleString(std::string_view locale) noexcept { - return StartsWithIgnoreCase(locale, "zh") ? Language::kChinese : Language::kEnglish; +Language LanguageFromLocaleString(std::string_view /*locale*/) noexcept { + // English is the only shipped language, so every locale string resolves to + // it. This function exists as the place where a future language's prefix + // rule lands (see the checklist in messages.def); keeping it separate from + // the environment walk lets that rule be unit-tested directly. + return Language::kEnglish; } Language DetectLanguageFromEnvironment() noexcept { - // 顺序遵循 POSIX:LC_ALL 压过 LC_MESSAGES,LC_MESSAGES 压过 LANG。 - // TETHERKIT_LANG 排在最前,好让用户在不动区域设置的前提下只改本程序。 + // POSIX order: LC_ALL beats LC_MESSAGES beats LANG. TETHERKIT_LANG comes + // first so a user can change just this program's language without touching + // the system locale. for (const char* name : {"TETHERKIT_LANG", "LC_ALL", "LC_MESSAGES", "LANG"}) { const std::string_view value = ReadEnvironment(name); if (value.empty()) { continue; } - // 取到第一个非空值就定了,不再往后看 —— 否则 LANG=zh_CN 会被更靠后的 - // 空值以外的任何东西干扰,语义也和 POSIX 不符。 + // The first non-empty value decides; nothing later is consulted. That is + // what makes LC_ALL actually outrank LANG instead of the other way + // around. return LanguageFromLocaleString(value); } return Language::kEnglish; @@ -103,11 +111,6 @@ bool ParseLanguage(std::string_view text, Language* out_language) noexcept { if (out_language == nullptr) { return false; } - if (StartsWithIgnoreCase(text, "zh") || StartsWithIgnoreCase(text, "chinese") || - text == "中文") { - *out_language = Language::kChinese; - return true; - } if (StartsWithIgnoreCase(text, "en") || StartsWithIgnoreCase(text, "english")) { *out_language = Language::kEnglish; return true; @@ -119,16 +122,20 @@ bool ParseLanguage(std::string_view text, Language* out_language) noexcept { return false; } -std::string_view LanguageTag(Language language) noexcept { - return language == Language::kChinese ? "zh" : "en"; +std::string_view LanguageTag(Language /*language*/) noexcept { + // English is the only shipped language. Keep the parameter so the signature + // survives a second language unchanged. + return "en"; } -std::string_view TextIn(Language language, Msg id) noexcept { +std::string_view TextIn(Language /*language*/, Msg id) noexcept { + // The language dispatch is here by design: with one table it always returns + // it, and a second language adds a branch, not a signature change. const auto index = static_cast(id); if (index >= kMessageCount) { return {}; } - return language == Language::kChinese ? kChineseTable[index] : kEnglishTable[index]; + return kMessageTable[index]; } std::string_view Text(Msg id) noexcept { @@ -142,15 +149,18 @@ std::string FormatMessage(Msg id, std::format_args args) noexcept { try { return std::vformat(pattern, args); } catch (...) { // NOLINT(bugprone-empty-catch) - // 只可能是译文里的占位符与调用点对不上(测试会拦住,但运行期宁可降级也 - // 不能抛)。退回未替换的原文:信息不全,总好过丢掉整条消息。 + // Only reachable when a message's placeholders mismatch its call site + // (a string-authoring mistake; the i18n tests catch it, but the runtime + // must degrade rather than throw). Falling back to the un-substituted + // original keeps a message present, just incomplete. return std::string{pattern}; } } std::string_view ContextSeparator() noexcept { - // 中文用全角冒号(前后不留空格),英文用半角冒号加空格。 - return GetLanguage() == Language::kChinese ? ":" : ": "; + // English separators keep one leading space ("outer: inner"). The function + // exists so a future language can pick its own punctuation. + return ": "; } } // namespace detail diff --git a/tests/language_guard.h b/tests/language_guard.h index 97dbca5..15c1c3e 100644 --- a/tests/language_guard.h +++ b/tests/language_guard.h @@ -1,20 +1,23 @@ -// 测试用:在一个作用域内把界面语言钉死,退出时还原。 +// Test helper: pin the interface language within a scope, restore on exit. // -// ★ 为什么需要它 ★ +// Why it exists: // -// 凡是断言「错误消息里提到了 XXX」的用例,都依赖当前语言。而语言是**进程级 -// 状态**,默认值(英文)还可能被别的用例改掉 —— 不钉死的话,这类用例的成败 -// 就取决于 doctest 的执行顺序,是最难查的那种间歇性失败。 +// Any test case along the lines of "the error message mentions XYZ" +// depends on the current language. Language is process-wide state and its +// default can be overwritten by other test cases, so without pinning, such +// assertions depend on doctest's execution order -- the nastiest kind of +// flaky failure to chase. // -// 所以:只要断言里出现了面向用户的字面文字,就在用例开头放一个 -// `const ScopedLanguage guard{Language::kChinese};`。 +// So: any test case that asserts user-facing literal text opens with +// `const ScopedLanguage guard{Language::kEnglish};`. #pragma once #include "tetherkit/common/i18n.h" namespace tetherkit::testing { -/// 作用域内切换语言,析构时还原成进入前的值。 +/// Switches the language within the scope; restores the entry value in the +/// destructor. class ScopedLanguage { public: explicit ScopedLanguage(Language language) noexcept : previous_(GetLanguage()) { diff --git a/tests/test_common_i18n.cc b/tests/test_common_i18n.cc index ebf6e62..7c6840f 100644 --- a/tests/test_common_i18n.cc +++ b/tests/test_common_i18n.cc @@ -1,14 +1,22 @@ -// 文案表的一致性检查。 +// Message-table consistency checks. // -// ★ 这个 suite 存在的唯一理由 ★ +// The one reason this suite exists: // -// Tr() 的格式串来自运行期查表,编译器**没法**再检查占位符与参数是否匹配 —— -// 这是把 std::format 换成 vformat 付出的代价。占位符对不上时 vformat 会抛 -// std::format_error(我们在 FormatMessage 里吞掉了,降级成未替换的原文), -// 于是错误会安静地变成「日志里有一行长得很奇怪」,可能几个月都没人发现。 +// Tr()'s format strings come from a runtime table, so the compiler cannot +// check placeholder/argument agreement -- the cost of using vformat instead +// of std::format. A mismatch makes vformat throw std::format_error, which +// FormatMessage swallows and degrades to un-substituted text, so the error +// would quietly surface as "one weird log line" and could go unnoticed for +// months. // -// 所以那道保障必须在这里补回来:逐条比对两种语言引用的参数下标与类型。 -// messages.def 里每加一条文案,都会自动被本 suite 覆盖,不需要手动登记。 +// This suite restores that guarantee: every message's placeholders must +// parse cleanly, use contiguous indices starting at 0, and substitute with +// real arguments. Adding a row to messages.def gets covered here +// automatically, no manual registration. +// +// When a language is added, the cross-language parity checks return: each +// table must expose the same placeholder index set and presentation types as +// the English one (see the checklist in messages.def). #include #include #include @@ -28,21 +36,24 @@ using tetherkit::Msg; constexpr std::size_t kMessageCount = static_cast(Msg::kMessageCount); -/// 一个格式串里出现的所有替换字段。 +/// Every replacement field found in one format string. struct ParsedFormat { - /// 下标 → 该下标用到的「表现类型」字符集合。 + /// Index -> set of presentation-type chars used for that index. /// - /// 表现类型就是格式规格的最后一个字母(`x` / `f` / `d`……),它决定了实参 - /// 必须是什么类型。宽度、对齐、填充刻意**不比**:译文换了语言之后想调列宽 - /// 是完全正当的,那不影响类型安全。 + /// The presentation type is the last letter of the format spec (x / f / d + /// ...); it decides what argument type must be passed. Width, alignment, + /// and fill are deliberately not compared: a translation that adjusts + /// column widths is perfectly legitimate and not a type hazard. std::map types; - /// 是否在同一个串里混用了自动编号与手工编号(std::format 会直接抛)。 + /// Whether automatic and manual numbering were mixed in one string + /// (std::format throws immediately). bool mixed_indexing = false; - /// 是否有未闭合的 `{`。 + /// Whether a `{` was left unclosed. bool malformed = false; }; -/// 从格式规格里取出表现类型字符。没有规格、或规格以非字母结尾时返回空串。 +/// Extracts the presentation-type char from a format spec. Empty when the +/// spec is empty or does not end in a letter. [[nodiscard]] std::string PresentationType(std::string_view spec) { if (spec.empty()) { return {}; @@ -51,10 +62,11 @@ struct ParsedFormat { return (std::isalpha(static_cast(last)) != 0) ? std::string{last} : std::string{}; } -/// 解析一个 std::format 格式串,收集替换字段。 +/// Parses a std::format string and collects the replacement fields. /// -/// 只实现我们文案里真正用到的语法子集:`{}`、`{n}`、`{:spec}`、`{n:spec}`, -/// 外加 `{{` / `}}` 转义。嵌套的动态宽度(`{:{}}`)本表里没有,出现即视为畸形。 +/// Only the syntax subset our strings use: `{}`, `{n}`, `{:spec}`, `{n:spec}`, +/// plus the `{{` / `}}` escapes. Nested dynamic widths (`{:{}`) do not exist in +/// this table and count as malformed. [[nodiscard]] ParsedFormat ParseFormat(std::string_view text) { ParsedFormat parsed; std::size_t auto_index = 0; @@ -63,7 +75,7 @@ struct ParsedFormat { for (std::size_t i = 0; i < text.size(); ++i) { if (text[i] == '}') { - // 合法的 `}}` 转义,跳过第二个。 + // A legal `}}` escape; skip the second one. if (i + 1 < text.size() && text[i + 1] == '}') { ++i; } @@ -73,7 +85,7 @@ struct ParsedFormat { continue; } if (i + 1 < text.size() && text[i + 1] == '{') { - ++i; // `{{` 转义 + ++i; // `{{` escape continue; } @@ -85,13 +97,13 @@ struct ParsedFormat { const std::string_view body = text.substr(i + 1, close - i - 1); i = close; - // 拆成「下标」与「规格」两半。 + // Split into "index" and "spec" halves. const std::size_t colon = body.find(':'); const std::string_view index_text = body.substr(0, colon); const std::string_view spec = colon == std::string_view::npos ? std::string_view{} : body.substr(colon + 1); if (spec.find('{') != std::string_view::npos) { - parsed.malformed = true; // 动态宽度,本表不该出现 + parsed.malformed = true; // dynamic width has no business in this table continue; } @@ -116,8 +128,9 @@ struct ParsedFormat { return parsed; } -/// 给报错用的可读标签。文案表里没存标识名(X-macro 只展开出文字),所以用 -/// 序号 + 英文原文的开头,足够定位到 messages.def 的哪一行。 +/// Readable label for failure output. The table does not store identifier +/// names (the X-macro expands to text only), so an index plus the start of the +/// English text is enough to locate the offending messages.def row. [[nodiscard]] std::string Label(std::size_t index) { const std::string_view english = tetherkit::TextIn(Language::kEnglish, static_cast(index)); return std::format("message #{} (\"{}\")", index, english.substr(0, 48)); @@ -127,13 +140,12 @@ struct ParsedFormat { TEST_SUITE("common.i18n") { -TEST_CASE("语言切换与查表") { +TEST_CASE("language switch and table lookup") { const Language original = tetherkit::GetLanguage(); - tetherkit::SetLanguage(Language::kChinese); - CHECK(tetherkit::GetLanguage() == Language::kChinese); - CHECK(tetherkit::Text(Msg::kCoreRunStateRunning) == "运行中"); - + // The selection machinery is process-wide and round-trips; today every + // value resolves to the English table, but these calls exercise the same + // path a second language will ride. tetherkit::SetLanguage(Language::kEnglish); CHECK(tetherkit::GetLanguage() == Language::kEnglish); CHECK(tetherkit::Text(Msg::kCoreRunStateRunning) == "running"); @@ -141,81 +153,57 @@ TEST_CASE("语言切换与查表") { tetherkit::SetLanguage(original); } -TEST_CASE("Tr 按当前语言渲染并替换参数") { +TEST_CASE("Tr renders the current language and substitutes arguments") { const Language original = tetherkit::GetLanguage(); - - tetherkit::SetLanguage(Language::kChinese); - const std::string chinese = tetherkit::Tr(Msg::kNetBpfBindFailed, "feth1"); - CHECK(chinese.find("feth1") != std::string::npos); - tetherkit::SetLanguage(Language::kEnglish); - const std::string english = tetherkit::Tr(Msg::kNetBpfBindFailed, "feth1"); - CHECK(english.find("feth1") != std::string::npos); - CHECK(english != chinese); + + const std::string text = tetherkit::Tr(Msg::kNetBpfBindFailed, "feth1"); + // The argument really was substituted, not left as an unreplaced field. + CHECK(text.find("feth1") != std::string::npos); + CHECK(text.find("{}") == std::string::npos); tetherkit::SetLanguage(original); } -TEST_CASE("每条文案两种语言都非空") { +TEST_CASE("every message is non-empty") { for (std::size_t i = 0; i < kMessageCount; ++i) { const auto id = static_cast(i); CAPTURE(i); - CHECK_FALSE(tetherkit::TextIn(Language::kChinese, id).empty()); CHECK_FALSE(tetherkit::TextIn(Language::kEnglish, id).empty()); } } -TEST_CASE("越界的 Msg 返回空串而不是崩溃") { +TEST_CASE("out-of-range Msg returns an empty string instead of crashing") { const auto out_of_range = static_cast(kMessageCount); CHECK(tetherkit::Text(out_of_range).empty()); - CHECK(tetherkit::TextIn(Language::kChinese, out_of_range).empty()); + CHECK(tetherkit::TextIn(Language::kEnglish, out_of_range).empty()); } -// 本 suite 的核心:编译器不再检查的那件事,在这里逐条检查。 -TEST_CASE("两种语言的占位符下标与类型一致") { +// This suite's core: the one check the compiler stopped doing, done entry by +// entry. +TEST_CASE("every message's placeholders are well-formed and contiguous") { for (std::size_t i = 0; i < kMessageCount; ++i) { - const auto id = static_cast(i); - const ParsedFormat chinese = ParseFormat(tetherkit::TextIn(Language::kChinese, id)); - const ParsedFormat english = ParseFormat(tetherkit::TextIn(Language::kEnglish, id)); + const ParsedFormat parsed = ParseFormat(tetherkit::TextIn(Language::kEnglish, static_cast(i))); CAPTURE(Label(i)); - CHECK_FALSE(chinese.malformed); - CHECK_FALSE(english.malformed); - // 同一个串里混用 `{}` 与 `{0}` 会让 std::format 直接抛,编译期又拦不住。 - CHECK_FALSE(chinese.mixed_indexing); - CHECK_FALSE(english.mixed_indexing); - - // 参数个数与下标必须完全一致:少一个就会渲染出残缺的句子,多一个直接抛。 - CHECK(chinese.types.size() == english.types.size()); - for (const auto& [index, type] : chinese.types) { - const auto found = english.types.find(index); - REQUIRE(found != english.types.end()); - // 类型字符不同意味着两边期待的实参类型不同,必然有一边会抛。 - CHECK(found->second == type); - } + CHECK_FALSE(parsed.malformed); + // Mixing `{}` with `{0}` in one string makes std::format throw at + // runtime; nothing at compile time can stop it. + CHECK_FALSE(parsed.mixed_indexing); - // 下标必须是连续的 0..n-1:留空档时 std::format 照样能渲染,但那说明有个 - // 实参被两种语言同时忽略了,几乎总是写错。 + // Indices must be exactly 0..n-1: a gap still renders, but it means an + // argument was silently ignored, which is almost always a mistake. std::size_t expected = 0; - for (const auto& [index, type] : chinese.types) { - CHECK(index == expected); + for (const auto& entry : parsed.types) { + CHECK(entry.first == expected); ++expected; } } } -TEST_CASE("ParseLanguage 认得常见写法") { +TEST_CASE("ParseLanguage understands the established spellings") { Language language = Language::kEnglish; - CHECK(tetherkit::ParseLanguage("zh", &language)); - CHECK(language == Language::kChinese); - CHECK(tetherkit::ParseLanguage("zh-Hans", &language)); - CHECK(language == Language::kChinese); - CHECK(tetherkit::ParseLanguage("zh_CN.UTF-8", &language)); - CHECK(language == Language::kChinese); - CHECK(tetherkit::ParseLanguage("Chinese", &language)); - CHECK(language == Language::kChinese); - CHECK(tetherkit::ParseLanguage("en", &language)); CHECK(language == Language::kEnglish); CHECK(tetherkit::ParseLanguage("EN_US", &language)); @@ -223,41 +211,45 @@ TEST_CASE("ParseLanguage 认得常见写法") { CHECK(tetherkit::ParseLanguage("english", &language)); CHECK(language == Language::kEnglish); - // auto 走环境推断,只要求它成功并给出两种语言之一。 + // auto defers to the environment; only require it to succeed and yield a + // valid language. CHECK(tetherkit::ParseLanguage("auto", &language)); CHECK_FALSE(tetherkit::ParseLanguage("klingon", &language)); CHECK_FALSE(tetherkit::ParseLanguage("", &language)); CHECK_FALSE(tetherkit::ParseLanguage("zh", nullptr)); + + // Chinese is fully out: no table, no spelling, not even as a parse. + CHECK_FALSE(tetherkit::ParseLanguage("zh", &language)); + CHECK_FALSE(tetherkit::ParseLanguage("Chinese", &language)); } -TEST_CASE("区域设置串只按 zh 前缀判定") { - CHECK(tetherkit::LanguageFromLocaleString("zh_CN.UTF-8") == Language::kChinese); - CHECK(tetherkit::LanguageFromLocaleString("ZH-hant") == Language::kChinese); +TEST_CASE("locale strings resolve through the current language set") { + // Documents today's behavior: with English as the only language, every + // locale string resolves to English. When a language is added, the prefix + // table goes into LanguageFromLocaleString() and these checks become + // per-prefix. + CHECK(tetherkit::LanguageFromLocaleString("zh_CN.UTF-8") == Language::kEnglish); + CHECK(tetherkit::LanguageFromLocaleString("ZH-hant") == Language::kEnglish); CHECK(tetherkit::LanguageFromLocaleString("en_US.UTF-8") == Language::kEnglish); CHECK(tetherkit::LanguageFromLocaleString("C") == Language::kEnglish); CHECK(tetherkit::LanguageFromLocaleString("") == Language::kEnglish); - // 「不以 zh 开头」就是英文,哪怕串里别处有 zh。 CHECK(tetherkit::LanguageFromLocaleString("en_zh") == Language::kEnglish); } -TEST_CASE("语言标签") { - CHECK(tetherkit::LanguageTag(Language::kChinese) == "zh"); +TEST_CASE("language tag") { CHECK(tetherkit::LanguageTag(Language::kEnglish) == "en"); } -TEST_CASE("错误上下文的分隔符随语言变化") { +TEST_CASE("error context separator is deterministic") { const Language original = tetherkit::GetLanguage(); - - // Context() 返回的是 view,必须先把 Error 落到具名变量上再取, - // 否则临时对象在语句末尾析构,view 当场悬垂。 - tetherkit::SetLanguage(Language::kChinese); - const tetherkit::Error chinese = tetherkit::Error::Generic("inner").WithContext("outer"); - CHECK(chinese.Context().starts_with("outer:")); - tetherkit::SetLanguage(Language::kEnglish); - const tetherkit::Error english = tetherkit::Error::Generic("inner").WithContext("outer"); - CHECK(english.Context().starts_with("outer: ")); + + // Context() returns a view, so the Error must land in a named variable + // before it is read; a temporary dies at the end of the statement and the + // view dangles on the spot. + const tetherkit::Error error = tetherkit::Error::Generic("inner").WithContext("outer"); + CHECK(error.Context().starts_with("outer: ")); tetherkit::SetLanguage(original); } diff --git a/tests/test_core_bridge.cc b/tests/test_core_bridge.cc index d58aba3..c05b46f 100644 --- a/tests/test_core_bridge.cc +++ b/tests/test_core_bridge.cc @@ -477,9 +477,9 @@ TEST_CASE("USB 提交失败被记为 I/O 错误,桥接层继续运行") { bridge.Stop(); } -TEST_CASE("统计行渲染出速率与丢包") { - // 统计行是面向用户的文案,下面按中文断言,先把语言钉死。 - const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kChinese}; +TEST_CASE("stats line renders rates and drops") { + // The stats line is user-facing text; pin the language before asserting. + const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kEnglish}; MockDataChannel channel; LoopbackLink link(LoopbackConfig{.sent_capacity = 4096}); Bridge bridge(channel, link, TestConfig()); @@ -496,7 +496,7 @@ TEST_CASE("统计行渲染出速率与丢包") { CHECK(line.find("TX") != std::string::npos); CHECK(line.find("pps") != std::string::npos); CHECK(line.find("Mbps") != std::string::npos); - CHECK(line.find("队列深度") != std::string::npos); + CHECK(line.find("queue depth") != std::string::npos); bridge.Stop(); } diff --git a/tests/test_net_link.cc b/tests/test_net_link.cc index d4f4c92..62958f6 100644 --- a/tests/test_net_link.cc +++ b/tests/test_net_link.cc @@ -223,17 +223,16 @@ TEST_CASE("BPF 记录头的大小陷阱:sizeof 是 20 但 bh_hdrlen 是 18") { CHECK(BPF_WORDALIGN(14 + kBpfHeaderMinBytes) - 14 == kBpfHeaderMinBytes); } -TEST_CASE("feth 创建期 sysctl 清单非空且都有说明") { +TEST_CASE("feth creation-time sysctl list is non-empty and each entry has a reason") { CHECK(std::size(kRequiredFethSysctls) > 0); for (const RequiredFethSysctl& entry : kRequiredFethSysctls) { CHECK(entry.name != nullptr); - // why 存的是文案标识,两种语言都必须真的有译文(漏译会渲染成空串, - // 于是 sysctl 报错里那句「原因:」后面什么都没有)。 - for (const auto language : {tetherkit::Language::kChinese, tetherkit::Language::kEnglish}) { - const std::string_view why = tetherkit::TextIn(language, entry.why); - // 说明必须是人话,不能只有一个词。 - CHECK(why.size() > 10); - } + // why holds a message identifier; the shipped language must really have + // text for it (a missing row would render as an empty string and leave + // "reason:" empty in sysctl errors). + const std::string_view why = tetherkit::TextIn(tetherkit::Language::kEnglish, entry.why); + // The reason must be a real sentence, not one word. + CHECK(why.size() > 10); } } diff --git a/tests/test_rndis_packet_codec.cc b/tests/test_rndis_packet_codec.cc index 1db3e83..960cf34 100644 --- a/tests/test_rndis_packet_codec.cc +++ b/tests/test_rndis_packet_codec.cc @@ -429,10 +429,10 @@ TEST_CASE("解码:帧视图指向传输缓冲内部,无拷贝") { CHECK(reader.BytesConsumed() == kPacketMsgHeaderBytes + 100); } -TEST_CASE("MalformedReason 名字完整") { - // 名字是面向用户的文案,会随语言变化,所以断言前先把语言钉死。 - const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kChinese}; - CHECK(MalformedReasonName(MalformedReason::kNone) == "无"); +TEST_CASE("MalformedReason names are complete") { + // The names are user-facing text and follow the language, so pin it first. + const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kEnglish}; + CHECK(MalformedReasonName(MalformedReason::kNone) == "none"); CHECK_FALSE(MalformedReasonName(MalformedReason::kNotPacketMessage).empty()); CHECK_FALSE(MalformedReasonName(MalformedReason::kDataOutOfBounds).empty()); CHECK_FALSE(MalformedReasonName(MalformedReason::kFrameTooLong).empty()); diff --git a/tests/test_rndis_state_machine.cc b/tests/test_rndis_state_machine.cc index 3d99bc6..1ed5d31 100644 --- a/tests/test_rndis_state_machine.cc +++ b/tests/test_rndis_state_machine.cc @@ -325,9 +325,9 @@ TEST_CASE("设备主动发 KEEPALIVE_MSG 时主机必须回 KEEPALIVE_CMPLT") { CHECK_MESSAGE(found, "主机没有回复设备发起的 KEEPALIVE"); } -TEST_CASE("面向连接设备的消息被明确拒绝") { - // 下面断言错误消息的中文措辞,先把语言钉死。 - const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kChinese}; +TEST_CASE("messages aimed at connection-oriented devices are explicitly rejected") { + // The error message wording is asserted below; pin the language. + const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kEnglish}; MockControlChannel channel; channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { if (LoadLe32(request.data() + kMessageTypeOffset) == ToRaw(MessageType::kInitialize)) { @@ -344,11 +344,12 @@ TEST_CASE("面向连接设备的消息被明确拒绝") { const auto status = machine.Start(); REQUIRE_FALSE(status.has_value()); - // 错误信息必须明确指出是面向连接设备,而不是含糊的「未知消息」。 - CHECK(status.error().ToString().find("面向连接") != std::string::npos); + // The message must explicitly name connection-oriented devices rather + // than the vague "unknown message". + CHECK(status.error().ToString().find("connection-oriented") != std::string::npos); } -TEST_CASE("保活正常时不报错,且设备活跃期间不发无谓保活") { +TEST_CASE("keepalive success reports no error and sends none while the device is active") { MockControlChannel channel; channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); RecordingObserver observer; @@ -363,7 +364,7 @@ TEST_CASE("保活正常时不报错,且设备活跃期间不发无谓保活") CHECK(channel.CountSent(MessageType::kKeepAlive) == 0); } -TEST_CASE("保活到期时发 KEEPALIVE 并在成功后清零失败计数") { +TEST_CASE("keepalive expiry sends KEEPALIVE and clears the failure count after success") { MockControlChannel channel; channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); RecordingObserver observer; @@ -378,9 +379,9 @@ TEST_CASE("保活到期时发 KEEPALIVE 并在成功后清零失败计数") { CHECK(observer.fatal_errors.empty()); } -TEST_CASE("连续保活失败达到阈值后通报致命错误") { - // 下面断言致命错误里的中文措辞,先把语言钉死。 - const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kChinese}; +TEST_CASE("reaching the keepalive failure threshold reports a fatal error") { + // The fatal error wording is asserted below; pin the language. + const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kEnglish}; MockControlChannel channel; // 设备只回 INITIALIZE / QUERY / SET,对 KEEPALIVE 一律不回 → 每次都超时。 channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { @@ -406,7 +407,7 @@ TEST_CASE("连续保活失败达到阈值后通报致命错误") { const auto third = machine.Poll(); CHECK_FALSE(third.has_value()); REQUIRE(observer.fatal_errors.size() == 1); - CHECK(observer.fatal_errors[0].find("保活") != std::string::npos); + CHECK(observer.fatal_errors[0].find("keep-alive") != std::string::npos); } TEST_CASE("软复位:AddressingReset 非零时必须重放包过滤") { From 26ec230563c0edc78b0f92f3dee1a2c9f2cc6d7a Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:59:54 +0530 Subject: [PATCH 2/6] feat(i18n)!: strip the Swift side, keep the plumbing Swift forbids single-element labeled tuples, so the rows are bare strings. --- AGENTS.md | 3 +- gui/Sources/TetherKitApp/TetherKitApp.swift | 11 +- .../TetherKitApp/Views/MenuBarPanel.swift | 4 +- .../TetherKitHelper/HelperService.swift | 9 +- gui/Sources/TetherKitIPC/Localization.swift | 131 ++-- .../TetherKitIPC/LocalizedStrings.swift | 666 ++++++++---------- .../TetherKitIPCTests/LocalizationTests.swift | 129 ++-- 7 files changed, 456 insertions(+), 497 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 92c392e..0f2c4cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -269,7 +269,8 @@ Swift 的 C++ 互操作吞不下,所以 C ABI 这一层不可省。 | 51 | `fix(ci): drop the dead bench args dispatch input` | ✅ | Third-round residual, addressed rather than left moot: the Run step carried the args dispatch input through an unquoted BENCH_ARGS (intentional word-splitting), but the input itself was dead: benchmarks/ contains no argv or argc use at all (grep-verified before the comment that says so was written), so no dispatch text could ever change the run. workflow_dispatch now declares no inputs, the Run step invokes tetherkit_bench bare behind pipefail+tee, and the inputs.args env-passage comment went with the plumbing it described | | 54 | `fix(ci): force arm64 target architecture in codeql build` | ✅ | Diagnosed CodeQL run 42513807606 (2026-11-08): job failed with linker error "Undefined symbols for architecture x86_64" and "found architecture 'arm64', required architecture 'x86_64'" against Homebrew's /opt/homebrew/Cellar/libusb/1.0.30/lib/libusb-1.0.dylib. CodeQL's macOS tracer forces traced child processes onto the x86_64 slice under Rosetta; without an explicit target architecture, AppleClang emits x86_64 objects that cannot link against arm64-only Homebrew dependencies. `codeql.yml` pins `CMAKE_OSX_ARCHITECTURES=arm64` so clang cross-emits arm64 regardless of driver process architecture; `arch -arm64` cannot be used instead because child builds forced native bypass CodeQL tracing. Hardened `codeql.yml` with a `pull_request` trigger scoped to `.github/**` so workflow edits self-verify. Verified green by two follow-up runs on the branch — 42578919426 (pull_request event) and 42578945162 (workflow_dispatch; 11m59s C++-analysis job span, 12m5s run wall, Build and analyze clean against arm64 Homebrew libusb) — sealing the failure-plus-verification pairing exactly like rows 39/41/42/47. | | 55 | `fix(ci): narrow codeql pr trigger, event-scope concurrency` | ✅ | Review round on row 54, five findings, four applied in codeql.yml plus this row's amendment: (1, consensus) pull_request paths narrowed `.github/**` -> `.github/workflows/**` - dependabot.yml and .github/scripts/ edits never reach the analyzer, while Dependabot's weekly grouped action-bump PR still matches by construction (it edits workflow files) and the header comment now states that ride-along as deliberate; (2, sharpest catch) the header's "No concurrency block: canceling would throw away the whole analysis" sentence predated the PR trigger and no longer held - a superseded PR commit's SARIF is simply replaced by the newer run - so an event-scoped concurrency group mirroring tests.yml landed (group `${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}`, cancel-in-progress: true) with a comment explaining why cancellation is safe within one event and why event_name in the key keeps a push to main from canceling the Monday cron; (3) row 54 amended (above) to cite verification runs 42578919426 and 42578945162, sealing the failure-plus-verification pairing the ledger's audit value depends on; (4, nit) the Build step comment now carries the hard-coded-on-purpose guard - under the tracer `uname -m` reports x86_64, so a `-DCMAKE_OSX_ARCHITECTURES=$(uname -m)` "simplification" would silently reintroduce the bug (the deliberate-choices-need-a-magnitude rule from pitfall 16); (5, INFO) the first-ever green analysis surfaced one pre-existing `cpp/use-after-free` at vendored third_party/doctest/doctest.h:3710 - resolved as a documented decline rather than config: `paths-ignore` is verifiably inert for C/C++ manual builds (codeql team statement in github/codeql-action#3890; GitHub docs confine paths filtering to build-mode none; the traced run would warn "path-based filtering not supported"), and SARIF post-filtering via advanced-security/filter-sarif means a new third-party action for exactly one benign vendored alert - the workflow header records the accepted baseline instead; the fork-PR-403 finding was retracted by the reviewers against current GitHub docs (SARIF upload on pull_request events is permitted under read-only fork tokens). Two disjoint antigravity/gemini-3.8-flash delegates applied the codeql.yml and AGENTS.md edits byte-exact within disjoint file scopes. Verification: the PR-triggered analysis run 42583242299 is green on 75634ad, and the new group's cancel-in-progress proved itself on this very push - the in-flight run 42582860934 for the superseded commit 84d8151 was canceled and replaced by the 75634ad run. | -| 56 | `feat(i18n)!: drop the Chinese column, keep the single-language wiring` (this branch) | ✅ | Maintainer decision: English-only, but preserving the multi-language plumbing so adding a language stays a bounded five-touch change (checklist now lives at the top of messages.def). messages.def went 843 -> 529 lines via a string-aware perl splice (TETHERKIT_MESSAGE(id, zh, en) -> (id, en)), 310 records; guards on kept column: no Han, no full-width punctuation. i18n.h/i18n.cci rewritten in English: Language enum reduced to kEnglish=0, single kMessageTable, TextIn kept as the designed per-language dispatch point, ParseLanguage no longer accepts zh/Chinese (returns false, CLI rejects with new English message), LanguageFromLocaleString parked at "always English" with the prefix rule to return there, ContextSeparator fixed to ": ". C ABI signature-stable: TK_LANGUAGE_CHINESE removed; ToLanguage returns nullopt for unknown values so a caller still sending the old Chinese value 1 is ignored and stays in English; helper XPC setLanguage method and protocolRevision 3 untouched (still applied, inert). Four ScopedLanguage guard sites flipped kChinese->kEnglish with English expectations (bridge stats "queue depth", CONDIS reject "connection-oriented", keepalive fatal "keep-alive", MalformedReason "none"); test_net_link's sysctl-why bilingual loop collapsed to the single table. test_common_i18n.cc rewritten: per-string placeholder well-formedness, no mixed indexing, contiguous indices 0..n-1; the cross-language parity checks were retired and are documented to come back when a second language lands. CLI help no longer advertises zh. Verification: cmake -Werror build clean, ctest 23/23 suites pass, smokes green (--version; --help shows the reworded --lang lines; --lang zh --list errors in English; --lang en --list and LC_ALL=zh_CN --list render English). GUI side deliberately untouched this phase, so `swift test` still exercises bilingual Swift tables and must be green throughout; the Swift strip is the next commit. Splice/validation scripts live in the session temp dir (not in the repo). Untracked docs/icon_resources/ and draft_icons/ predate this work and were left alone. | +| 56 | `feat(i18n)!: drop the Chinese column, keep the single-language wiring` (this branch) | ✅ | Maintainer decision: English-only, but preserving the multi-language plumbing so adding a language stays a bounded five-touch change (checklist now lives at the top of messages.def). messages.def went 843 -> 529 lines via a string-aware perl splice (TETHERKIT_MESSAGE(id, zh, en) -> (id, en)), 310 records; guards on kept column: no Han, no full-width punctuation. i18n.h/i18n.cc rewritten in English: Language enum reduced to kEnglish=0, single kMessageTable, TextIn kept as the designed per-language dispatch point, ParseLanguage no longer accepts zh/Chinese (returns false, CLI rejects with new English message), LanguageFromLocaleString parked at "always English" with the prefix rule to return there, ContextSeparator fixed to ": ". C ABI signature-stable: TK_LANGUAGE_CHINESE removed; ToLanguage returns nullopt for unknown values so a caller still sending the old Chinese value 1 is ignored and stays in English; helper XPC setLanguage method and protocolRevision 3 untouched (still applied, inert). Four ScopedLanguage guard sites flipped kChinese->kEnglish with English expectations (bridge stats "queue depth", CONDIS reject "connection-oriented", keepalive fatal "keep-alive", MalformedReason "none"); test_net_link's sysctl-why bilingual loop collapsed to the single table. test_common_i18n.cc rewritten: per-string placeholder well-formedness, no mixed indexing, contiguous indices 0..n-1; the cross-language parity checks were retired and are documented to come back when a second language lands. CLI help no longer advertises zh. Verification: cmake -Werror build clean, ctest 23/23 suites pass, smokes green (--version; --help shows the reworded --lang lines; --lang zh --list errors in English; --lang en --list and LC_ALL=zh_CN --list render English). GUI side deliberately untouched this phase, so `swift test` still exercises bilingual Swift tables and must be green throughout; the Swift strip is the next commit. Splice/validation scripts live in the session temp dir (not in the repo). Untracked docs/icon_resources/ and draft_icons/ predate this work and were left alone. | +| 57 | `feat(i18n)!: strip the Swift side to English-only, keep the add-a-language wiring` (this branch) | ✅ | Phase 2 complement to row 56. LocalizedStrings.swift went 743 -> 685 lines via a string-aware perl splice: every `localizations` switch row lost its Chinese first argument, with two guards - the dropped arg must contain Han or full-width punctuation OR be identical to the kept one (the languageEnglish native-name row needs the second rule), and the kept column must be Han- and fullwidth-free; kept spans are preserved byte-exact, including multi-line `+`-concatenation layouts (installer/update/permission strings); the file header and 42 MARK/comment lines went to English via an exact-line map; the `languageChinese` key is gone entirely (both Pickers hard-code `Text(verbatim: "English")` under the tag, so the key had no non-test consumers). Language enum is `.english` only with `cValue` constant 0; LanguagePreference loses `.chinese`, so a stale saved rawValue "chinese" decodes to nil and falls back to `.system` (graceful, symmetric with the C ABI ignoring unknown values); `L10n.apply` and `text(_:in:)` survive as the add-a-language dispatch points; new internal `L10n.localeLanguage` is parked always-English, exactly mirroring LanguageFromLocaleString on the C++ side, and `systemLanguage` still reads Locale.preferredLanguages through it; HelperService.setLanguage keeps its ignore-unknown semantics (an old App still sending "chinese" leaves the helper in English); both language menus now offer Follow system + English. **Swift constraint determined empirically** (tiny probe program): single-element labeled tuples are illegal - `cannot create a single-element tuple with an element label` - so the planned `(english: String)` one-member tuple is unrepresentable and rows are bare strings; when a language is added the property becomes a real labeled tuple and every case appends one labeled argument, the same way C++ rows `(id, en)` grow. LocalizationTests rewritten for the single table, still 7 cases: per-key placeholder contiguity, the %ld-integer rule, language resolution incl. localeLanguage, and formatting substitution; the cross-language placeholder/parity checks retire until a second language and the suite header says they return then. Verification: swift build green (3.3s, only the pre-existing libtetherkit dylib-version warning); swift test 30/30 - TetherKitIPCTests 17/17 (Authorization 2 + HelperConstants 8 + the 7 rewritten LocalizationTests) and TetherKitUIModelTests 13/13; the current-state snapshot below says "17 IPC + 7 UIModel = 24", which predates the sidebar test additions, not this work. Also folded in here: the row-56 typo fix (i18n.cci -> i18n.h). Splice/unwrap scripts live in the session temp dir. | ### 当前状态 diff --git a/gui/Sources/TetherKitApp/TetherKitApp.swift b/gui/Sources/TetherKitApp/TetherKitApp.swift index 4f388cc..8f58f14 100644 --- a/gui/Sources/TetherKitApp/TetherKitApp.swift +++ b/gui/Sources/TetherKitApp/TetherKitApp.swift @@ -110,11 +110,13 @@ private struct AppMenuItems: View { } } -/// App 菜单里的语言选择。 +/// The language choice in the App menu. /// -/// 每一项都直接写母语名字(「中文」/「English」),不跟随当前界面语言翻译 —— -/// 语言菜单是给「现在看不懂界面」的人用的,把选项也翻译掉就等于没有出口。 -/// 「跟随系统」是唯一例外:它描述的是行为而非语言,所以随界面走。 +/// Every option shows its native name ("English") and is deliberately not +/// translated with the interface: the menu exists for the person who cannot +/// read the interface right now, so the way out must stay readable. +/// "Follow system" is the one exception: it describes behavior, not a +/// language, so it follows the interface. private struct LanguageMenu: View { var model: AppModel @@ -122,7 +124,6 @@ private struct LanguageMenu: View { Menu(L(.languageMenuTitle)) { Picker(L(.languageLabel), selection: Bindable(model).languagePreference) { Text(L(.languageSystem)).tag(LanguagePreference.system) - Text(verbatim: "中文").tag(LanguagePreference.chinese) Text(verbatim: "English").tag(LanguagePreference.english) } .pickerStyle(.inline) diff --git a/gui/Sources/TetherKitApp/Views/MenuBarPanel.swift b/gui/Sources/TetherKitApp/Views/MenuBarPanel.swift index f957e00..c3e974d 100644 --- a/gui/Sources/TetherKitApp/Views/MenuBarPanel.swift +++ b/gui/Sources/TetherKitApp/Views/MenuBarPanel.swift @@ -142,12 +142,12 @@ struct MenuBarPanel: View { } } - /// 语言开关。选项写母语名字,不跟随界面语言翻译 —— 见 LanguageMenu 的说明。 + /// The language toggle. Options show native names, not translations -- + /// see LanguageMenu for why. private var languageMenu: some View { Menu { Picker(L(.languageLabel), selection: Bindable(model).languagePreference) { Text(L(.languageSystem)).tag(LanguagePreference.system) - Text(verbatim: "中文").tag(LanguagePreference.chinese) Text(verbatim: "English").tag(LanguagePreference.english) } .pickerStyle(.inline) diff --git a/gui/Sources/TetherKitHelper/HelperService.swift b/gui/Sources/TetherKitHelper/HelperService.swift index df4ecd4..5ebac87 100644 --- a/gui/Sources/TetherKitHelper/HelperService.swift +++ b/gui/Sources/TetherKitHelper/HelperService.swift @@ -87,10 +87,13 @@ final class HelperService: NSObject, TetherKitHelperProtocol { } func setLanguage(_ rawValue: String, reply: @escaping () -> Void) { - // 认不出来就保持原样。宁可继续用上一种语言,也不要因为 App 传了个新值 - // 就退回默认 —— 那会表现成「切了个语言,helper 的日志反而变回英文」。 + // An unrecognized value leaves everything untouched on purpose: keep + // rendering in the current language instead of falling back to the + // default because the App sent something new. An App that still sends + // the removed Chinese spelling ("chinese") decodes to nil here, and + // the helper simply stays in English. if let language = Language(rawValue: rawValue) { - L10n.apply(language == .chinese ? .chinese : .english) + L10n.apply(.english) TetherKitLibrary.setLanguage(language) } reply() diff --git a/gui/Sources/TetherKitIPC/Localization.swift b/gui/Sources/TetherKitIPC/Localization.swift index c4708fa..0dd8b70 100644 --- a/gui/Sources/TetherKitIPC/Localization.swift +++ b/gui/Sources/TetherKitIPC/Localization.swift @@ -1,74 +1,84 @@ import Foundation -// 界面文案的语言切换。 +// User-facing strings and language switching for the GUI and the helper. // -// ★ 为什么不用 Localizable.strings / String(localized:) ★ +// Why not Localizable.strings / String(localized:): // -// 标准做法要把 .lproj 目录打进 bundle,而本工程有三个互相独立的产物形态: -// * `swift build` 出来的裸可执行文件(开发时直接跑); -// * Scripts/build-gui.sh 手工拼出来的 TetherKit.app; -// * 装到 /Library/PrivilegedHelperTools 的 **裸** helper 可执行文件。 -// 最后这个是致命的:helper 旁边没有、也不该有一个资源 bundle,而它同样要 -// 产生给用户看的文字(提示、错误)。走 bundle 的话 helper 只能永远输出 -// 开发语言,或者得在安装脚本里再搬一套资源过去。 +// The standard approach bundles .lproj directories, and this project ships +// three independent artifact shapes: +// * the bare executable that `swift build` produces (run during dev); +// * the TetherKit.app assembled by Scripts/build-gui.sh; +// * the **bare** helper executable that lives in +// /Library/PrivilegedHelperTools. +// The last one is fatal: no resource bundle sits next to the helper, and +// none should, yet it still produces user-facing text (prompts, errors). +// Going through bundles means the helper either speaks the build language +// forever or the installer has to ship a second copy of the resources. // -// 把文案编进二进制就没有这些问题:三种形态行为完全一致,安装脚本不用动, -// 也不存在「装到别的机器上找不到 .lproj 于是全变英文」这类运行期故障。 -// 顺带和 C++ 侧(include/tetherkit/common/messages.def)是同一个心智模型。 +// Compiling the strings into the binary has none of those problems: all +// three shapes behave identically, install scripts stay untouched, and +// there is no "installed on another machine, .lproj missing, everything +// falls back to the development language" class of runtime failure. It also +// mirrors the C++ side (include/tetherkit/common/messages.def): one mental +// model. // -// 代价是用不上 Xcode 的字符串目录编辑器 —— 而这个工程本来就没有 Xcode -// 工程文件,代价为零。 +// The cost is Xcode's string-catalog editor -- this repo has no Xcode +// project file anyway, so the cost is zero. // -// ★ 加一条文案 ★ +// Adding a message: // -// 1. 在 LocalizedStrings.swift 的 `L10nKey` 里加一个 case; -// 2. 在同文件的 `localizations` switch 里补上中英两版。 -// switch 是穷尽的,**漏了编译不过** —— 这比 C++ 那边的 X-macro 还强一档。 +// 1. Add a case to `L10nKey` in LocalizedStrings.swift; +// 2. add its text to the `localizations` switch in the same file. +// The switch is exhaustive: a missing entry is a compile error -- one notch +// stronger than the C++ X-macro side. -/// 用户能选的语言偏好。`system` 表示跟随 macOS。 +/// The language preference the user can pick. `system` follows macOS. public enum LanguagePreference: String, CaseIterable, Codable, Sendable { case system - case chinese case english } -/// 实际生效的语言。偏好为 `system` 时由 `L10n` 解析成这两者之一。 +/// The language actually in effect. The `system` preference resolves into one +/// of these through `L10n`. public enum Language: String, CaseIterable, Codable, Sendable { - case chinese case english - /// 与 C ABI 的 `tk_language_t` 对齐(TK_LANGUAGE_ENGLISH = 0、CHINESE = 1)。 - public var cValue: Int32 { self == .chinese ? 1 : 0 } + /// Aligned with the C ABI's tk_language_t (TK_LANGUAGE_ENGLISH = 0). A + /// language is one more enum value; on the wire, foreign values are + /// ignored by every consumer, so callers keep working and stay in English. + public var cValue: Int32 { 0 } } -/// 文案查表与语言状态。 +/// The string table and language state. /// -/// 状态是**进程级**的:日志与提示会从多个线程产生,做成线程局部只会让同一次 -/// 会话的输出出现两种语言。读远多于写,用一把小锁足够 —— 每次查表多一次 -/// 无争用的加锁,相对 SwiftUI 一次渲染的开销可以忽略。 +/// State is **process-wide**: logs and prompts come from multiple threads, +/// and thread-local state would split a single session's output across +/// languages. Reads far outnumber writes; one small lock is enough -- one +/// uncontended lock per table lookup is noise next to one SwiftUI render. public enum L10n { private static let lock = NSLock() nonisolated(unsafe) private static var storedPreference: LanguagePreference = .system nonisolated(unsafe) private static var storedLanguage: Language = resolve(.system) - /// 当前生效的语言。 + /// The language actually in effect. public static var language: Language { lock.lock() defer { lock.unlock() } return storedLanguage } - /// 当前的偏好设置(可能是 `.system`)。 + /// The current preference (possibly `.system`). public static var preference: LanguagePreference { lock.lock() defer { lock.unlock() } return storedPreference } - /// 应用一个偏好,并返回解析后实际生效的语言。 + /// Applies a preference and returns the language that becomes effective. /// - /// 调用方拿到返回值后**还要**把它推给 libtetherkit(见 - /// `TetherKitLibrary.setLanguage`),否则库产生的日志会和界面语言不一致。 + /// After taking the return value, the caller must **also** push it into + /// libtetherkit (see `TetherKitLibrary.setLanguage`), or library logs and + /// the interface language drift apart. @discardableResult public static func apply(_ preference: LanguagePreference) -> Language { let resolved = resolve(preference) @@ -79,52 +89,65 @@ public enum L10n { return resolved } - /// macOS 当前的首选语言落到我们支持的两种上。 + /// macOS's current preferred language, mapped onto the languages we ship. /// - /// 看 `Locale.preferredLanguages` 而不是 `Locale.current`:后者受区域格式 - /// 设置影响(有人把地区设成中国但界面语言是英文),前者才是「界面该用哪种 - /// 语言」的那份列表。 + /// Reads `Locale.preferredLanguages` rather than `Locale.current`: the + /// latter follows the region-format setting (someone can have the region + /// set to China while the interface language is English); the former is + /// the actual list of interface languages. public static var systemLanguage: Language { - let preferred = Locale.preferredLanguages.first ?? "en" - return preferred.lowercased().hasPrefix("zh") ? .chinese : .english + localeLanguage(Locale.preferredLanguages.first ?? "en") + } + + /// Maps a locale string (`"zh_CN.UTF-8"`, `"en_US"`, `"C"`, ...) to a + /// language. + /// + /// English is the only shipped language, so every input resolves to it. + /// This is where a language's prefix rule lands later (prefix match, case + /// insensitive) -- kept separate from the preference walk so the rule can + /// be unit-tested directly, exactly like LanguageFromLocaleString on the + /// C++ side (see the checklist at the top of messages.def). + static func localeLanguage(_ locale: String) -> Language { + .english } private static func resolve(_ preference: LanguagePreference) -> Language { switch preference { case .system: return systemLanguage - case .chinese: return .chinese case .english: return .english } } - /// 取一条文案在当前语言下的原文(未做参数替换)。 + /// The raw (unsubstituted) text of a message in the current language. public static func text(_ key: L10nKey) -> String { - let (chinese, english) = key.localizations - return language == .chinese ? chinese : english + text(key, in: language) } - /// 取一条文案在**指定**语言下的原文。测试用它逐语言核对占位符。 + /// The raw text of a message in the given language. Tests use it to check + /// specific tables; the per-language dispatch is the one function a new + /// language changes (the tuple gains its member). public static func text(_ key: L10nKey, in language: Language) -> String { - let (chinese, english) = key.localizations - return language == .chinese ? chinese : english + key.localizations } } -/// 取一条文案,并按 `String(format:)` 的规则替换参数。 +/// Looks up a message and substitutes arguments per `String(format:)`. /// -/// 做成全局函数而不是 `L10n.text(...)`:调用点有两百多处、绝大多数在 SwiftUI 的 -/// 视图体里,那里每多一个字都会挤掉真正重要的布局代码。名字短到一个字母也不会 -/// 歧义 —— 见到 `L(` 就知道是「这里有一条要翻译的文案」。 +/// A global function rather than `L10n.text(...)`: the two hundred-plus call +/// sites are mostly inside SwiftUI view bodies, where every character of +/// boilerplate crowds out the actual layout code. A one-letter name stays +/// unambiguous -- `L(` reads as "there is a translatable string here". /// -/// 占位符用 printf 风格(`%@` 字符串、`%d` 整数、`%.1f` 浮点)。需要换语序时 -/// 用带位置的形式:`%1$@`、`%2$d`。 +/// Placeholders are printf-style (`%@` strings, `%ld` integers, `%.1f` +/// floats). To reorder arguments, use positional forms: `%1$@`, `%2$ld`. public func L(_ key: L10nKey, _ arguments: CVarArg...) -> String { let pattern = L10n.text(key) return arguments.isEmpty ? pattern : String(format: pattern, arguments: arguments) } -/// 同上,但指定语言。给 helper 用 —— 它按 App 推过来的语言渲染,而不是按 -/// 自己的进程状态(root 进程没有「用户偏好」这回事)。 +/// Same, but in a specified language. For the helper -- it renders in the +/// language the App pushed, not its own process state (a root process has no +/// "user preference" concept). public func L(_ key: L10nKey, in language: Language, _ arguments: CVarArg...) -> String { let pattern = L10n.text(key, in: language) return arguments.isEmpty ? pattern : String(format: pattern, arguments: arguments) diff --git a/gui/Sources/TetherKitIPC/LocalizedStrings.swift b/gui/Sources/TetherKitIPC/LocalizedStrings.swift index a31df99..9904703 100644 --- a/gui/Sources/TetherKitIPC/LocalizedStrings.swift +++ b/gui/Sources/TetherKitIPC/LocalizedStrings.swift @@ -1,26 +1,36 @@ import Foundation -// 图形界面与 helper 的全部面向用户文案。 +// All user-facing strings for the GUI and the helper. // -// 加一条:在 `L10nKey` 里加 case,再到下面的 switch 里补中英两版。switch 是 -// 穷尽的,漏了直接编译不过。 +// Adding one: add a case to `L10nKey`, then its text to the `localizations` +// switch below. The switch is exhaustive, so a missing entry is a compile +// error. // -// ★ 占位符的规矩 ★ +// How to add a language (the wiring deliberately stays in place with one +// language, so this is a bounded change, not a re-architecture): // -// 走 `String(format:)`,printf 风格:`%@` 字符串、`%ld` 整数、`%.1f` 浮点。 -// **两种语言的占位符必须一一对应**(个数、顺序、类型)。译文需要换语序时, -// 两边都改成带位置的形式(`%1$@`、`%2$ld`)—— 只有一边带位置也是合法的 -// printf,但那样两个串的参数含义就悄悄错位了。 +// 1. Mirror the C++ side: add the value to the `Language` enum here in the +// package, to tk_language_t (include/tetherkit/capi/tetherkit_c.h), and +// to the add-a-language checklist at the top of messages.def. +// 2. Give every row of the `localizations` switch one more labeled argument. +// 3. Dispatch on the language in L10n.text(_:in:). The helper already +// follows the App through the existing setLanguage plumbing. +// 4. Extend LocalizationTests so every table covers every message: parity +// across languages checks placeholder positions and conversions. // -// 调用点传参前统一转成 `Int` / `Double` / `String`:`UInt32` 之类的整型直接 -// 喂给 `%ld` 在 32/64 位上的行为不一致,转一次最省心。 +// Placeholder rules (checked entry by entry by LocalizationTests): // -// 这两条由 TetherKitIPCTests 的 LocalizationTests 逐条核对。 - -/// 一条文案的标识。 +// Strings go through `String(format:)`, printf style: `%@` strings, `%ld` +// integers, `%.1f` floats. Positions must be contiguous (1...n). Call sites +// convert to `Int` / `Double` / `String` before passing: feeding a `UInt32` +// to `%ld` behaves inconsistently between 32/64-bit, one explicit +// conversion is the safe path. Reorder with positional forms (`%1$@`, +// `%2$ld`) when the word order differs. + +/// The identifier of one user-facing message. public enum L10nKey: String, CaseIterable, Sendable { - // MARK: - 通用 + // MARK: - Common case ok case cancel @@ -37,22 +47,21 @@ public enum L10nKey: String, CaseIterable, Sendable { case notConfigured case listSeparator - // MARK: - 语言设置 + // MARK: - Language settings case languageLabel case languageSystem - case languageChinese case languageEnglish case languageMenuTitle - // MARK: - 数据模型(Models.swift) + // MARK: - Data model (Models.swift) case usbDeviceFallbackName case ipModeDhcp case ipModeManual case ipModeNone - // MARK: - 授权(Authorization.swift) + // MARK: - Authorization (Authorization.swift) case authorizationCancelled case authorizationDenied @@ -61,11 +70,11 @@ public enum L10nKey: String, CaseIterable, Sendable { case authorizationRestoreFailed case authorizationRightMissing - // MARK: - 库错误(TetherKitError.swift) + // MARK: - Library errors (TetherKitError.swift) case libraryGenericFailure - // MARK: - 会话事件(TetherKitSession.swift) + // MARK: - Session events (TetherKitSession.swift) case eventLinkUp case eventLinkDown @@ -73,7 +82,7 @@ public enum L10nKey: String, CaseIterable, Sendable { case eventDeviceReset case eventNegotiated - // MARK: - 网络参数校验(NetworkConfigurator.swift) + // MARK: - Network validation (NetworkConfigurator.swift) case invalidIPAddress case invalidNetmask @@ -81,7 +90,7 @@ public enum L10nKey: String, CaseIterable, Sendable { case routerRequiredForDefaultRoute case invalidDNSServer - // MARK: - helper 服务(HelperService.swift / main.swift / InstallerMode.swift) + // MARK: - Helper service (HelperService.swift / main.swift / InstallerMode.swift) case helperSessionAlreadyRunning case helperSessionStopped @@ -96,7 +105,7 @@ public enum L10nKey: String, CaseIterable, Sendable { case installerUseTerminal case installerScriptMissing - // MARK: - helper 客户端(HelperClient.swift / HelperInstaller.swift) + // MARK: - Helper client (HelperClient.swift / HelperInstaller.swift) case helperConnectFailed case helperReplyUnparsable @@ -104,13 +113,13 @@ public enum L10nKey: String, CaseIterable, Sendable { case installerAPIUnavailable case installerLaunchFailed - // MARK: - 检查更新(UpdateChecker.swift) + // MARK: - Update check (UpdateChecker.swift) case updateNoReleases case updateHTTPStatus case updateBadResponse - // MARK: - 访达别名(FinderAlias.swift) + // MARK: - Finder alias (FinderAlias.swift) case aliasSkippedNotBundle case aliasSkippedAlreadyInApplications @@ -118,7 +127,7 @@ public enum L10nKey: String, CaseIterable, Sendable { case aliasAlreadyPresent case aliasCreated - // MARK: - 连接状态(DesignSystem.swift) + // MARK: - Connection status (DesignSystem.swift) case statusDisconnected case statusConnecting @@ -128,7 +137,7 @@ public enum L10nKey: String, CaseIterable, Sendable { case statusStopped case statusFailed - // MARK: - 应用模型(AppModel.swift) + // MARK: - App model (AppModel.swift) case authPromptSession case authPromptInstall @@ -180,7 +189,7 @@ public enum L10nKey: String, CaseIterable, Sendable { case sysctlNeedsFixTitle case sysctlNeedsFixBody - // MARK: - 设备卡(DeviceCard.swift) + // MARK: - Device card (DeviceCard.swift) case usbDeviceSectionTitle case cannotRescanWhileRunning @@ -199,7 +208,7 @@ public enum L10nKey: String, CaseIterable, Sendable { case compatibilityMode case compatibilityModeTooltip - // MARK: - 日志卡(LogCard.swift) + // MARK: - Log card (LogCard.swift) case logDroppedNotice case logLevelLabel @@ -223,7 +232,7 @@ public enum L10nKey: String, CaseIterable, Sendable { case pageManageTitle case manageHelperSectionTitle - // MARK: - 菜单栏面板(MenuBarPanel.swift) + // MARK: - Menu bar panel (MenuBarPanel.swift) case downstreamShort case upstreamShort @@ -234,7 +243,7 @@ public enum L10nKey: String, CaseIterable, Sendable { case quit case quitTooltip - // MARK: - 网络卡(NetworkCard.swift) + // MARK: - Network card (NetworkCard.swift) case dnsEffectivenessTooltip case ipModeLabel @@ -254,7 +263,7 @@ public enum L10nKey: String, CaseIterable, Sendable { case notEffective case noAddressYet - // MARK: - 状态卡(StatusHeroCard.swift) + // MARK: - Status card (StatusHeroCard.swift) case linkUp case linkDown @@ -265,7 +274,7 @@ public enum L10nKey: String, CaseIterable, Sendable { case interfaceCreating case connectDisabledHint - // MARK: - 吞吐卡(ThroughputCard.swift) + // MARK: - Throughput card (ThroughputCard.swift) case throughputLive case throughputDownstream @@ -289,455 +298,388 @@ public enum L10nKey: String, CaseIterable, Sendable { extension L10nKey { - /// 一条文案的中英两版。**两边的占位符必须一一对应。** + /// The text of the message in the shipped language. Adding a language + /// turns this into a multi-element labeled tuple and gives every case one + /// more labeled argument; see the checklist at the top of this file. // swiftlint:disable:next function_body_length cyclomatic_complexity - public var localizations: (chinese: String, english: String) { + public var localizations: String { switch self { - // MARK: 通用 - - case .ok: return ("好", "OK") - case .cancel: return ("取消", "Cancel") - case .copy: return ("复制", "Copy") - case .copied: return ("已复制", "Copied") - case .add: return ("添加", "Add") - case .apply: return ("应用", "Apply") - case .connect: return ("连接", "Connect") - case .disconnect: return ("断开", "Disconnect") - case .uninstall: return ("卸载", "Uninstall") - case .ipAddress: return ("IP 地址", "IP address") - case .netmask: return ("子网掩码", "Subnet mask") - case .router: return ("网关", "Router") - case .notConfigured: return ("未配置", "Not configured") - case .listSeparator: return ("、", ", ") - - // MARK: 语言设置 - - case .languageLabel: return ("界面语言", "Language") - case .languageSystem: return ("跟随系统", "Follow system") - case .languageChinese: return ("中文", "中文") - case .languageEnglish: return ("English", "English") - case .languageMenuTitle: return ("语言", "Language") - - // MARK: 数据模型 - - case .usbDeviceFallbackName: return ("USB 设备 %04lx:%04lx", "USB device %04lx:%04lx") - case .ipModeDhcp: return ("自动(DHCP)", "Automatic (DHCP)") - case .ipModeManual: return ("静态 IP", "Static IP") - case .ipModeNone: return ("不配置", "None") - - // MARK: 授权 - - case .authorizationCancelled: return ("已取消授权", "Authorization cancelled") - case .authorizationDenied: return ("授权未通过(%ld)", "Authorization was denied (%ld)") + // MARK: Common + + case .ok: return "OK" + case .cancel: return "Cancel" + case .copy: return "Copy" + case .copied: return "Copied" + case .add: return "Add" + case .apply: return "Apply" + case .connect: return "Connect" + case .disconnect: return "Disconnect" + case .uninstall: return "Uninstall" + case .ipAddress: return "IP address" + case .netmask: return "Subnet mask" + case .router: return "Router" + case .notConfigured: return "Not configured" + case .listSeparator: return ", " + + // MARK: Language settings + + case .languageLabel: return "Language" + case .languageSystem: return "Follow system" + case .languageEnglish: return "English" + case .languageMenuTitle: return "Language" + + // MARK: Data model + + case .usbDeviceFallbackName: return "USB device %04lx:%04lx" + case .ipModeDhcp: return "Automatic (DHCP)" + case .ipModeManual: return "Static IP" + case .ipModeNone: return "None" + + // MARK: Authorization + + case .authorizationCancelled: return "Authorization cancelled" + case .authorizationDenied: return "Authorization was denied (%ld)" case .authorizationSessionFailed: - return ("无法创建授权会话(%ld)", "Cannot create an authorization session (%ld)") + return "Cannot create an authorization session (%ld)" case .authorizationBlobMalformed: - return ("授权凭据格式不正确", "The authorization credential is malformed") + return "The authorization credential is malformed" case .authorizationRestoreFailed: - return ("无法还原授权凭据(%ld)", "Cannot restore the authorization credential (%ld)") + return "Cannot restore the authorization credential (%ld)" case .authorizationRightMissing: - return ("调用方没有执行该操作所需的授权(%ld)", - "The caller lacks the authorization this operation requires (%ld)") + return "The caller lacks the authorization this operation requires (%ld)" - // MARK: 库错误 + // MARK: Library errors case .libraryGenericFailure: - return ("操作失败(错误码 %ld)", "The operation failed (error code %ld)") + return "The operation failed (error code %ld)" - // MARK: 会话事件 + // MARK: Session events - case .eventLinkUp: return ("链路已连接", "Link is up") - case .eventLinkDown: return ("链路已断开", "Link is down") + case .eventLinkUp: return "Link is up" + case .eventLinkDown: return "Link is down" case .eventDeviceResetReplayed: - return ("设备已软复位,寻址信息已重放", - "The device soft-reset; addressing information has been replayed") - case .eventDeviceReset: return ("设备已软复位", "The device soft-reset") + return "The device soft-reset; addressing information has been replayed" + case .eventDeviceReset: return "The device soft-reset" case .eventNegotiated: - return ("RNDIS 协商完成:MTU %1$ld,链路 %2$ld Mbps", - "RNDIS negotiation complete: MTU %1$ld, link %2$ld Mbps") + return "RNDIS negotiation complete: MTU %1$ld, link %2$ld Mbps" - // MARK: 网络参数校验 + // MARK: Network validation - case .invalidIPAddress: return ("IP 地址格式不正确", "The IP address is not valid") + case .invalidIPAddress: return "The IP address is not valid" case .invalidNetmask: - return ("子网掩码不正确(必须是连续的掩码,如 255.255.255.0)", - "The subnet mask is not valid (it must be contiguous, e.g. 255.255.255.0)") - case .invalidRouter: return ("网关地址格式不正确", "The router address is not valid") + return "The subnet mask is not valid (it must be contiguous, e.g. 255.255.255.0)" + case .invalidRouter: return "The router address is not valid" case .routerRequiredForDefaultRoute: - return ("要把流量默认走这张网卡,必须填写网关地址", - "Routing traffic through this interface by default requires a router address") + return "Routing traffic through this interface by default requires a router address" case .invalidDNSServer: - return ("DNS 服务器 %@ 格式不正确", "DNS server %@ is not a valid address") + return "DNS server %@ is not a valid address" - // MARK: helper 服务 + // MARK: Helper service - case .helperSessionAlreadyRunning: return ("会话已经在运行了", "A session is already running") - case .helperSessionStopped: return ("会话已停止", "Session stopped") + case .helperSessionAlreadyRunning: return "A session is already running" + case .helperSessionStopped: return "Session stopped" case .helperNetworkApplied: - return ("已应用网络配置:%@", "Applied the network configuration: %@") + return "Applied the network configuration: %@" case .helperRequestDecodeFailed: - return ("请求参数无法解析:%@", "Cannot decode the request arguments: %@") - case .helperReplyEncodeFailed: return ("应答编码失败:%@", "Cannot encode the reply: %@") + return "Cannot decode the request arguments: %@" + case .helperReplyEncodeFailed: return "Cannot encode the reply: %@" case .helperOrphansCleaned: - return ("已清理 %ld 张上次残留的虚拟网卡", - "Cleaned up %ld leftover virtual interface(s) from a previous run") + return "Cleaned up %ld leftover virtual interface(s) from a previous run" case .helperOrphanCleanupFailed: - return ("清理残留虚拟网卡失败:%@", - "Failed to clean up leftover virtual interfaces: %@") - case .helperSigtermReceived: return ("收到 SIGTERM,正在停机", "Received SIGTERM; shutting down") - case .helperReady: return ("tetherkit-helper 已就绪:%@", "tetherkit-helper ready: %@") + return "Failed to clean up leftover virtual interfaces: %@" + case .helperSigtermReceived: return "Received SIGTERM; shutting down" + case .helperReady: return "tetherkit-helper ready: %@" case .installerNeedsRoot: - return ("错误:无法取得 root(euid=%1$ld,ruid=%2$ld)。", - "Error: cannot obtain root (euid=%1$ld, ruid=%2$ld).") + return "Error: cannot obtain root (euid=%1$ld, ruid=%2$ld)." case .installerUseTerminal: - return ("请改用终端:sudo %@", "Use the terminal instead: sudo %@") - case .installerScriptMissing: return ("错误:找不到脚本 %@", "Error: script %@ not found") + return "Use the terminal instead: sudo %@" + case .installerScriptMissing: return "Error: script %@ not found" - // MARK: helper 客户端 + // MARK: Helper client case .helperConnectFailed: - return ("无法连接到特权组件:%@", "Cannot reach the privileged helper: %@") + return "Cannot reach the privileged helper: %@" case .helperReplyUnparsable: - return ("特权组件的应答无法解析,可能是版本不一致", - "The helper's reply could not be parsed; the versions may not match") - // 这两条各自都以「请改用终端」收尾,因此把两句合成一条 —— 拆成两半再拼 - // 的话,中文之间不该有空格、英文之间必须有,join 的写法一定会错一边。 + return "The helper's reply could not be parsed; the versions may not match" + // Both of these two messages end with "use the terminal instead", so the two + // sentences are merged -- Chinese clauses need no space, English ones do, and a join would get one side wrong. case .installerNoPayload: - return ("这份 TetherKit 不带安装载荷(可能是开发构建)。" - + "请在终端执行:sudo ./gui/Scripts/install-helper.sh", - "This copy of TetherKit carries no installer payload (likely a dev build). " - + "Run this in a terminal: sudo ./gui/Scripts/install-helper.sh") + return "This copy of TetherKit carries no installer payload (likely a dev build). " + + "Run this in a terminal: sudo ./gui/Scripts/install-helper.sh" case .installerAPIUnavailable: - return ("当前系统不再提供 App 内安装所需的接口。" - + "请在终端执行:sudo ./gui/Scripts/install-helper.sh", - "This version of macOS no longer offers the API in-app installation needs. " - + "Run this in a terminal: sudo ./gui/Scripts/install-helper.sh") + return "This version of macOS no longer offers the API in-app installation needs. " + + "Run this in a terminal: sudo ./gui/Scripts/install-helper.sh" case .installerLaunchFailed: - return ("无法启动安装/卸载程序(%ld)", - "Cannot launch the install/uninstall program (%ld)") + return "Cannot launch the install/uninstall program (%ld)" - // MARK: 检查更新 + // MARK: Update check - case .updateNoReleases: return ("仓库还没有发布任何版本", "The repository has no releases yet") - case .updateHTTPStatus: return ("GitHub 返回了 %ld", "GitHub returned %ld") - case .updateBadResponse: return ("响应格式不符合预期", "The response was not in the expected format") + case .updateNoReleases: return "The repository has no releases yet" + case .updateHTTPStatus: return "GitHub returned %ld" + case .updateBadResponse: return "The response was not in the expected format" - // MARK: 访达别名 + // MARK: Finder alias case .aliasSkippedNotBundle: - return ("跳过:不是 .app 包(开发构建)", "Skipped: not an .app bundle (dev build)") + return "Skipped: not an .app bundle (dev build)" case .aliasSkippedAlreadyInApplications: - return ("跳过:App 已在 /Applications 里", "Skipped: the app already lives in /Applications") + return "Skipped: the app already lives in /Applications" case .aliasFailed: - return ("失败:/Applications 与 ~/Applications 都没能写入别名", - "Failed: could not write the alias to /Applications or ~/Applications") - case .aliasAlreadyPresent: return ("别名已就绪:%@", "Alias already in place: %@") - case .aliasCreated: return ("已建立别名:%1$@ → %2$@", "Created alias: %1$@ -> %2$@") + return "Failed: could not write the alias to /Applications or ~/Applications" + case .aliasAlreadyPresent: return "Alias already in place: %@" + case .aliasCreated: return "Created alias: %1$@ -> %2$@" - // MARK: 连接状态 + // MARK: Connection status - case .statusDisconnected: return ("未连接", "Not connected") - case .statusConnecting: return ("正在连接", "Connecting") - case .statusConnected: return ("已连接", "Connected") - case .statusReadyLinkDown: return ("已就绪(链路未连通)", "Ready (link down)") - case .statusDisconnecting: return ("正在断开", "Disconnecting") - case .statusStopped: return ("已断开", "Disconnected") - case .statusFailed: return ("连接失败", "Connection failed") + case .statusDisconnected: return "Not connected" + case .statusConnecting: return "Connecting" + case .statusConnected: return "Connected" + case .statusReadyLinkDown: return "Ready (link down)" + case .statusDisconnecting: return "Disconnecting" + case .statusStopped: return "Disconnected" + case .statusFailed: return "Connection failed" - // MARK: 应用模型 + // MARK: App model case .authPromptSession: - return ("TetherKit 需要管理员权限来创建虚拟网卡、打开数据链路并配置 IP 地址。", - "TetherKit needs administrator privileges to create the virtual interface, " - + "open the data link and configure IP addresses.") + return "TetherKit needs administrator privileges to create the virtual interface, " + + "open the data link and configure IP addresses." case .authPromptInstall: - return ("TetherKit 需要管理员权限来安装特权组件" - + "(复制到 /Library/PrivilegedHelperTools 并注册系统服务)。", - "TetherKit needs administrator privileges to install the privileged helper " + return "TetherKit needs administrator privileges to install the privileged helper " + "(copy it to /Library/PrivilegedHelperTools and register the system " - + "service).") + + "service)." case .authPromptUninstall: - return ("TetherKit 需要管理员权限来卸载特权组件" - + "(注销系统服务并删除 /Library 里的组件文件)。", - "TetherKit needs administrator privileges to uninstall the privileged helper " - + "(unregister the system service and delete its files under /Library).") + return "TetherKit needs administrator privileges to uninstall the privileged helper " + + "(unregister the system service and delete its files under /Library)." case .interfaceNotReadyYet: - return ("虚拟网卡还没创建,请先连接设备", - "The virtual interface does not exist yet -- connect a device first") + return "The virtual interface does not exist yet -- connect a device first" case .installScriptRanButUnreachable: - return ("安装脚本执行完毕,但仍连不上特权组件。", - "The install script finished, but the helper is still unreachable.") + return "The install script finished, but the helper is still unreachable." case .uninstallScriptRanButStillAlive: - return ("卸载脚本执行完毕,但特权组件仍在响应。", - "The uninstall script finished, but the helper is still responding.") - case .scriptOutput: return ("%1$@脚本输出:\n%2$@", "%1$@Script output:\n%2$@") - case .scriptNoOutput: return ("(无输出)", "(no output)") + return "The uninstall script finished, but the helper is still responding." + case .scriptOutput: return "%1$@Script output:\n%2$@" + case .scriptNoOutput: return "(no output)" // MARK: Main window, dialogs and helper management - case .closeWindow: return ("关闭窗口", "Close Window") - case .menuCheckForUpdates: return ("检查更新…", "Check for Updates…") - case .alertOperationFailed: return ("操作失败", "Operation failed") - case .confirmUninstallTitle: return ("卸载特权组件?", "Uninstall the privileged helper?") - case .updateCheckTitle: return ("检查更新", "Check for updates") - case .openReleasePage: return ("前往发布页", "Open the release page") - case .copyBrewUpgradeCommand: return ("复制 brew 升级命令", "Copy the brew upgrade command") + case .closeWindow: return "Close Window" + case .menuCheckForUpdates: return "Check for Updates…" + case .alertOperationFailed: return "Operation failed" + case .confirmUninstallTitle: return "Uninstall the privileged helper?" + case .updateCheckTitle: return "Check for updates" + case .openReleasePage: return "Open the release page" + case .copyBrewUpgradeCommand: return "Copy the brew upgrade command" case .updateUpToDate: - return ("当前已是最新版本(v%@)。", "You are on the latest version (v%@).") + return "You are on the latest version (v%@)." case .updateAvailable: - return ("发现新版本 v%@。通过 Homebrew 安装的话," - + "在终端执行 brew upgrade tetherkit;" - + "从源码构建的话,拉取最新代码重新编译即可。", - "Version v%@ is available. If you installed via Homebrew, run " + return "Version v%@ is available. If you installed via Homebrew, run " + "brew upgrade tetherkit in a terminal; if you build from source, pull the " - + "latest code and rebuild.") - case .updateCheckFailed: return ("无法完成检查:%@", "The check could not be completed: %@") + + "latest code and rebuild." + case .updateCheckFailed: return "The check could not be completed: %@" case .updateDevBuild: - return ("这是开发构建(没有版本号),无从比较。", - "This is a development build with no version number, so there is nothing to " - + "compare against.") + return "This is a development build with no version number, so there is nothing to " + + "compare against." case .uninstallExplanation: - return ("将注销系统服务并删除 /Library 里的组件文件,之后随时可以重新安装。", - "This unregisters the system service and deletes the helper files under " - + "/Library. You can reinstall it at any time.") + return "This unregisters the system service and deletes the helper files under " + + "/Library. You can reinstall it at any time." case .uninstallWhileRunningWarning: - return ("当前连接会被断开、虚拟网卡销毁。", - "The current connection will be dropped and the virtual interface destroyed.") - case .helperComponentVersion: return ("特权组件 · %@", "Helper · %@") - case .helperVersionMismatch: return ("特权组件 %1$@ · App %2$@", "Helper %1$@ · app %2$@") + return "The current connection will be dropped and the virtual interface destroyed." + case .helperComponentVersion: return "Helper · %@" + case .helperVersionMismatch: return "Helper %1$@ · app %2$@" case .helperVersionMismatchTooltip: - return ("装着的特权组件仍是 %1$@,而 App 已经是 %2$@。真正干活的是组件里的那份库," - + "不更新的话新版修好的问题在连接里依旧存在。" - + "点一下就用 App 自带的组件重装一遍,需要一次管理员授权。", - "The installed helper is still %1$@ while the app is %2$@. The library inside " + return "The installed helper is still %1$@ while the app is %2$@. The library inside " + "the helper is what does the actual work, so anything the new version fixes " + "stays unfixed until the helper is updated too. One click reinstalls the " + "helper the app ships with; macOS will ask for an administrator password " - + "once.") - case .confirmHelperUpdateTitle: return ("更新特权组件?", "Update the privileged helper?") + + "once." + case .confirmHelperUpdateTitle: return "Update the privileged helper?" case .helperUpdateWhileRunningWarning: - return ("更新要先注销旧组件,当前连接会被断开、虚拟网卡销毁。装好后重新连接即可。", - "Updating unregisters the old helper first, so the current connection will be " + return "Updating unregisters the old helper first, so the current connection will be " + "dropped and the virtual interface destroyed. Just reconnect once it is " - + "installed.") - case .helperUpdateBadge: return ("有新版 %@", "%@ available") - case .uninstallHelperMenuItem: return ("卸载特权组件…", "Uninstall the helper…") - case .checkingHelper: return ("正在检查特权组件……", "Checking the privileged helper…") - case .needInstallTitle: return ("需要先安装特权组件", "The privileged helper must be installed") + + "installed." + case .helperUpdateBadge: return "%@ available" + case .uninstallHelperMenuItem: return "Uninstall the helper…" + case .checkingHelper: return "Checking the privileged helper…" + case .needInstallTitle: return "The privileged helper must be installed" case .needInstallBody: - return ("创建虚拟网卡和打开数据链路需要管理员权限。TetherKit 把这部分\n" - + "放在一个独立的后台组件里,App 本身以普通用户身份运行。", - "Creating the virtual interface and opening the data link require\n" + return "Creating the virtual interface and opening the data link require\n" + "administrator privileges. TetherKit keeps that work in a separate\n" - + "background component so the app itself runs as a normal user.") - case .installHelperButton: return ("安装特权组件", "Install the helper") + + "background component so the app itself runs as a normal user." + case .installHelperButton: return "Install the helper" case .installHelperDetail: - return ("组件会被装到 /Library/PrivilegedHelperTools 并注册为\n" - + "LaunchDaemon。装好后本页自动恢复,不需要重启 App。", - "It is installed to /Library/PrivilegedHelperTools and registered\n" + return "It is installed to /Library/PrivilegedHelperTools and registered\n" + "as a LaunchDaemon. This page recovers on its own afterwards --\n" - + "no need to restart the app.") + + "no need to restart the app." case .showConnectFailureDetail: - return ("查看连接失败的详细原因", "Show why the connection failed") - case .helperNeedsUpdateTitle: return ("特权组件需要更新", "The privileged helper needs updating") + return "Show why the connection failed" + case .helperNeedsUpdateTitle: return "The privileged helper needs updating" case .helperNeedsUpdateBody: - return ("已安装的特权组件是升级前的版本,与当前 App 的通信接口对不上" - + "(组件 v%1$@,App 需要 v%2$@)。", - "The installed helper predates this app and speaks a different protocol " - + "(helper v%1$@, app needs v%2$@).") - case .updateHelperButton: return ("更新特权组件", "Update the helper") + return "The installed helper predates this app and speaks a different protocol " + + "(helper v%1$@, app needs v%2$@)." + case .updateHelperButton: return "Update the helper" case .updateHelperDetail: - return ("更新会先注销旧版本再装新的,装好后本页自动恢复。", - "Updating unregisters the old version before installing the new one. " - + "This page recovers on its own afterwards.") - case .installingProgress: return ("正在安装……", "Installing…") + return "Updating unregisters the old version before installing the new one. " + + "This page recovers on its own afterwards." + case .installingProgress: return "Installing…" case .authorizationPromptHint: - return ("会弹出系统授权框,需要输入一次管理员密码", - "macOS will ask once for an administrator password") - case .installViaTerminal: return ("改用终端安装", "Install from the terminal instead") + return "macOS will ask once for an administrator password" + case .installViaTerminal: return "Install from the terminal instead" case .installViaTerminalHint: - return ("在仓库根目录执行,效果与按钮完全相同。", - "Run this from the repository root; it does exactly what the button does.") - case .copiedToClipboard: return ("已复制到剪贴板", "Copied to the clipboard") - case .copyCommand: return ("复制命令", "Copy the command") - case .sysctlNeedsFixTitle: return ("系统参数需要调整", "System parameters need adjusting") + return "Run this from the repository root; it does exactly what the button does." + case .copiedToClipboard: return "Copied to the clipboard" + case .copyCommand: return "Copy the command" + case .sysctlNeedsFixTitle: return "System parameters need adjusting" case .sysctlNeedsFixBody: - return ("下面这些开关会在虚拟网卡**创建时**被快照进去,创建后再改无效,\n" - + "因此必须先修正再连接:", - "These switches are snapshotted into the virtual interface **when it is\n" + return "These switches are snapshotted into the virtual interface **when it is\n" + "created**, so changing them afterwards has no effect. Fix them before\n" - + "connecting:") + + "connecting:" - // MARK: 设备卡 + // MARK: Device card - case .usbDeviceSectionTitle: return ("USB 设备", "USB device") + case .usbDeviceSectionTitle: return "USB device" case .cannotRescanWhileRunning: - return ("运行中无法刷新设备列表", "The device list cannot be refreshed while running") - case .rescanUSBDevices: return ("重新扫描 USB 设备", "Rescan USB devices") - case .noDeviceDetected: return ("没有检测到设备", "No device detected") + return "The device list cannot be refreshed while running" + case .rescanUSBDevices: return "Rescan USB devices" + case .noDeviceDetected: return "No device detected" case .deviceChecklistCable: - return ("① 用的是数据线,而不是只能供电的充电线", - "1. The cable carries data and is not charge-only") + return "1. The cable carries data and is not charge-only" case .deviceChecklistTethering: - return ("② 手机上已打开「USB 网络共享 / USB tethering」", - "2. \"USB tethering\" is switched on for the phone") + return "2. \"USB tethering\" is switched on for the phone" case .deviceChecklistUnlocked: - return ("③ 手机已解锁并信任本机", "3. The phone is unlocked and trusts this Mac") - case .mtuBytes: return ("%@ 字节", "%@ bytes") + return "3. The phone is unlocked and trusts this Mac" + case .mtuBytes: return "%@ bytes" case .mtuHelp: - return ("超出设备能力时会在协商阶段自动下调。", - "Values beyond the device's capability are lowered during negotiation.") + return "Values beyond the device's capability are lowered during negotiation." case .mtuHelpWithLimit: - return ("超出设备能力时会在协商阶段自动下调(本机上限 %ld)。", - "Values beyond the device's capability are lowered during negotiation " - + "(this Mac allows up to %ld).") + return "Values beyond the device's capability are lowered during negotiation " + + "(this Mac allows up to %ld)." case .mtuTooltip: - return ("协商阶段以设备汇报的能力为准,填大了会自动下调。" - + "上限受系统的 net.link.fake.max_mtu 约束。", - "Negotiation honours whatever the device reports, so an oversized value is " + return "Negotiation honours whatever the device reports, so an oversized value is " + "lowered automatically. The ceiling comes from the system's " - + "net.link.fake.max_mtu.") - case .adoptDeviceMAC: return ("采用设备汇报的 MAC 地址", "Adopt the MAC address the device reports") + + "net.link.fake.max_mtu." + case .adoptDeviceMAC: return "Adopt the MAC address the device reports" case .adoptDeviceMACHelp: - return ("只有排查 MAC 冲突时才需要关掉。", - "Only turn this off when investigating a MAC address conflict.") + return "Only turn this off when investigating a MAC address conflict." case .adoptDeviceMACTooltip: - return ("RNDIS 语义下设备就是这块网卡,对端的 ARP 表与 DHCP 租约都按设备的 MAC 建立。", - "Under RNDIS the device *is* the network interface, so the peer's ARP table " - + "and DHCP leases are all keyed on the device's MAC.") - case .compatibilityMode: return ("兼容模式", "Compatibility mode") + return "Under RNDIS the device *is* the network interface, so the peer's ARP table " + + "and DHCP leases are all keyed on the device's MAC." + case .compatibilityMode: return "Compatibility mode" case .compatibilityModeTooltip: - return ("该设备的 CDC 描述符不规范,已按 Android 的惯例推断接口编号", - "This device's CDC descriptors are non-standard, so the interface numbers " - + "were inferred using the usual Android layout") + return "This device's CDC descriptors are non-standard, so the interface numbers " + + "were inferred using the usual Android layout" - // MARK: 日志卡 + // MARK: Log card case .logDroppedNotice: - return ("有 %ld 条日志因缓冲写满被丢弃", "%ld log lines were dropped because the buffer filled up") - case .logLevelLabel: return ("级别", "Level") - case .logLevelAll: return ("全部", "All") - case .logLevelDebug: return ("调试", "Debug") - case .logLevelInfo: return ("信息", "Info") - case .logLevelWarning: return ("警告", "Warning") - case .logLevelError: return ("错误", "Error") - case .logAutoScroll: return ("自动滚动", "Auto-scroll") - case .logCopyAll: return ("复制全部日志", "Copy all log lines") - case .logClear: return ("清空日志", "Clear the log") - case .logRepeatSuffix: return ("(×%1$ld,自 %2$@ 起)", " (x%1$ld, since %2$@)") + return "%ld log lines were dropped because the buffer filled up" + case .logLevelLabel: return "Level" + case .logLevelAll: return "All" + case .logLevelDebug: return "Debug" + case .logLevelInfo: return "Info" + case .logLevelWarning: return "Warning" + case .logLevelError: return "Error" + case .logAutoScroll: return "Auto-scroll" + case .logCopyAll: return "Copy all log lines" + case .logClear: return "Clear the log" + case .logRepeatSuffix: return " (x%1$ld, since %2$@)" case .logRepeatTooltip: - return ("这句话连续出现了 %1$ld 次,首次在 %2$@", - "This line repeated %1$ld times in a row, first at %2$@") + return "This line repeated %1$ld times in a row, first at %2$@" - // MARK: 菜单栏面板 + // MARK: Menu bar panel - case .downstreamShort: return ("下行", "Down") - case .upstreamShort: return ("上行", "Up") - case .noIPConfigured: return ("未配置 IP", "No IP address") + case .downstreamShort: return "Down" + case .upstreamShort: return "Up" + case .noIPConfigured: return "No IP address" case .menuBarNoDevice: - return ("未检测到设备。用数据线连接手机并开启 USB 网络共享。", - "No device detected. Connect a phone with a data cable and turn on USB " - + "tethering.") + return "No device detected. Connect a phone with a data cable and turn on USB " + + "tethering." case .menuBarReady: - return ("已就绪:%@。打开主窗口即可连接。", - "Ready: %@. Open the main window to connect.") - case .openMainWindow: return ("打开主窗口", "Open the main window") - case .quit: return ("退出", "Quit") + return "Ready: %@. Open the main window to connect." + case .openMainWindow: return "Open the main window" + case .quit: return "Quit" case .quitTooltip: - return ("退出只是关闭界面,已建立的连接会继续运行。", - "Quitting only closes the interface; an established connection keeps running.") + return "Quitting only closes the interface; an established connection keeps running." - // MARK: 网络卡 + // MARK: Network card case .dnsEffectivenessTooltip: - return ("静态模式下 DNS 是否生效取决于系统的解析器管理,请以「当前生效」里的回读结果为准。", - "In manual mode, whether DNS takes effect is up to the system resolver, so " - + "trust what \"Currently effective\" reads back.") - case .ipModeLabel: return ("上网方式", "Configure IPv4") + return "In manual mode, whether DNS takes effect is up to the system resolver, so " + + "trust what \"Currently effective\" reads back." + case .ipModeLabel: return "Configure IPv4" case .connectBeforeConfiguring: - return ("连接设备后才能配置网络", "Connect a device before configuring the network") + return "Connect a device before configuring the network" case .dhcpHelp: - return ("由系统向设备申请地址,DNS 与路由都自动配好", - "The system asks the device for an address; DNS and routes are set up " - + "automatically") + return "The system asks the device for an address; DNS and routes are set up " + + "automatically" case .dhcpTooltip: - return ("绝大多数手机的 USB 网络共享都自带 DHCP 服务器,这是推荐选项。" - + "应用后最多等待 10 秒;超时通常意味着设备侧没有开启网络共享。", - "Nearly every phone's USB tethering runs its own DHCP server, so this is the " + return "Nearly every phone's USB tethering runs its own DHCP server, so this is the " + "recommended choice. Applying it waits up to 10 seconds; a timeout usually " - + "means tethering is not enabled on the device.") - case .routerOptional: return ("可留空", "Optional") - case .deleteThisEntry: return ("删除这一条", "Remove this entry") - case .addDNSServer: return ("添加 DNS 服务器(最多 4 条)", "Add a DNS server (up to 4)") - case .setDefaultRoute: return ("让所有流量默认走这张网卡", "Route all traffic through this interface") + + "means tethering is not enabled on the device." + case .routerOptional: return "Optional" + case .deleteThisEntry: return "Remove this entry" + case .addDNSServer: return "Add a DNS server (up to 4)" + case .setDefaultRoute: return "Route all traffic through this interface" case .setDefaultRouteHelp: - return ("不开启时,只有明确绑定到本网卡的流量走它。", - "When off, only traffic explicitly bound to this interface uses it.") + return "When off, only traffic explicitly bound to this interface uses it." case .setDefaultRouteTooltip: - return ("不开启时其余流量仍走当前的主网络。如果本机没有别的可用网络," - + "通常不需要开 —— 系统会自己把它选为主服务。", - "When off, the rest of your traffic keeps using the current primary network. " + return "When off, the rest of your traffic keeps using the current primary network. " + "If this Mac has no other network available you usually do not need this -- " - + "the system picks this interface as the primary service on its own.") - case .clearConfiguration: return ("撤销配置", "Clear the configuration") + + "the system picks this interface as the primary service on its own." + case .clearConfiguration: return "Clear the configuration" case .clearConfigurationTooltip: - return ("等同于 ipconfig set <网卡> NONE,会移除地址与相关路由", - "Equivalent to ipconfig set NONE; removes the address and its " - + "routes") - case .currentlyEffective: return ("当前生效", "Currently effective") - case .primaryDefaultRoute: return ("主默认路由", "Primary default route") - case .notEffective: return ("未生效", "Not in effect") + return "Equivalent to ipconfig set NONE; removes the address and its " + + "routes" + case .currentlyEffective: return "Currently effective" + case .primaryDefaultRoute: return "Primary default route" + case .notEffective: return "Not in effect" case .noAddressYet: - return ("%@ 目前没有 IP 地址。选择上网方式后点「应用」。", - "%@ has no IP address yet. Pick a configuration and press Apply.") + return "%@ has no IP address yet. Pick a configuration and press Apply." - // MARK: 状态卡 + // MARK: Status card - case .linkUp: return ("链路已连通", "Link up") - case .linkDown: return ("链路未连通", "Link down") - case .transferPaused: return ("已暂停搬运", "Transfer paused") - case .pendingDevice: return ("待连接:%@", "Ready to connect: %@") + case .linkUp: return "Link up" + case .linkDown: return "Link down" + case .transferPaused: return "Transfer paused" + case .pendingDevice: return "Ready to connect: %@" case .noRNDISDeviceDetected: - return ("没有检测到 RNDIS 设备。请用数据线连接手机,并在手机上打开「USB 网络共享」。", - "No RNDIS device detected. Connect a phone with a data cable and turn on " - + "\"USB tethering\" on the phone.") - case .interfaceLabel: return ("网卡", "Interface") - case .interfaceCreating: return ("创建中…", "Creating…") + return "No RNDIS device detected. Connect a phone with a data cable and turn on " + + "\"USB tethering\" on the phone." + case .interfaceLabel: return "Interface" + case .interfaceCreating: return "Creating…" case .connectDisabledHint: - return ("先连接一台开启了 USB 网络共享的设备", - "Connect a device with USB tethering switched on first") - - // MARK: 吞吐卡 - - case .throughputLive: return ("实时", "Live") - case .throughputDownstream: return ("下行(设备 → 本机)", "Down (device -> Mac)") - case .throughputUpstream: return ("上行(本机 → 设备)", "Up (Mac -> device)") - case .throughputDownstreamFPS: return ("下行帧率", "Down frame rate") - case .throughputUpstreamFPS: return ("上行帧率", "Up frame rate") - case .chartTime: return ("时间", "Time") - case .chartRate: return ("速率", "Rate") - case .chartDirection: return ("方向", "Direction") - case .throughputCollecting: return ("正在采集…", "Collecting…") + return "Connect a device with USB tethering switched on first" + + // MARK: Throughput card + + case .throughputLive: return "Live" + case .throughputDownstream: return "Down (device -> Mac)" + case .throughputUpstream: return "Up (Mac -> device)" + case .throughputDownstreamFPS: return "Down frame rate" + case .throughputUpstreamFPS: return "Up frame rate" + case .chartTime: return "Time" + case .chartRate: return "Rate" + case .chartDirection: return "Direction" + case .throughputCollecting: return "Collecting…" case .throughputPlaceholder: - return ("连接后显示实时吞吐", "Live throughput appears once you connect") - case .totalDownstream: return ("累计下行", "Total down") - case .totalUpstream: return ("累计上行", "Total up") - case .downstreamFrames: return ("下行帧数", "Down frames") - case .upstreamFrames: return ("上行帧数", "Up frames") - case .downstreamDropped: return ("下行丢弃", "Down dropped") - case .upstreamDropped: return ("上行丢弃", "Up dropped") - case .kernelDrops: return ("内核丢包", "Kernel drops") - case .transmitBackpressure: return ("发送背压", "TX backpressure") + return "Live throughput appears once you connect" + case .totalDownstream: return "Total down" + case .totalUpstream: return "Total up" + case .downstreamFrames: return "Down frames" + case .upstreamFrames: return "Up frames" + case .downstreamDropped: return "Down dropped" + case .upstreamDropped: return "Up dropped" + case .kernelDrops: return "Kernel drops" + case .transmitBackpressure: return "TX backpressure" // MARK: Sidebar pages - case .pageConnectionTitle: return ("连接", "Connection") - case .pageNetworkTitle: return ("网络", "Network") - case .pageThroughputTitle: return ("吞吐", "Throughput") - case .pageLogTitle: return ("日志", "Log") - case .pageManageTitle: return ("管理", "Manage") - case .manageHelperSectionTitle: return ("特权组件", "Privileged helper") + case .pageConnectionTitle: return "Connection" + case .pageNetworkTitle: return "Network" + case .pageThroughputTitle: return "Throughput" + case .pageLogTitle: return "Log" + case .pageManageTitle: return "Manage" + case .manageHelperSectionTitle: return "Privileged helper" } } } diff --git a/gui/Tests/TetherKitIPCTests/LocalizationTests.swift b/gui/Tests/TetherKitIPCTests/LocalizationTests.swift index 895b4ef..2289370 100644 --- a/gui/Tests/TetherKitIPCTests/LocalizationTests.swift +++ b/gui/Tests/TetherKitIPCTests/LocalizationTests.swift @@ -2,21 +2,29 @@ import XCTest @testable import TetherKitIPC -/// 文案表的一致性检查。 +/// Consistency checks for the string table. /// -/// ★ 这些用例存在的唯一理由 ★ +/// Why this suite exists (the one reason): /// -/// `L(...)` 走 `String(format:)`,格式串来自运行期查表 —— 编译器管不到它。 -/// 两种语言的占位符对不上时,`String(format:)` **不会报错**:它会安静地按 -/// 自己读到的类型去解释一个根本不是那个类型的参数。`%@` 撞上一个整数就是 -/// 把整数当指针解引用,直接崩;`%ld` 撞上字符串则打出一个地址。两种都是 -/// 「翻译写错 → 运行期炸在用户机器上」,而且中文版好好的、英文版才炸。 +/// `L(...)` goes through `String(format:)`, and the format string comes +/// from a runtime table -- the compiler never sees it. `String(format:)` +/// does **not** complain about a mismatched placeholder: it quietly +/// reinterprets the argument in whatever type it read. `%@` hitting an +/// integer dereferences the integer as a pointer and crashes; `%ld` hitting +/// a string prints an address. Both are "a typo in a string -> runtime +/// crash on a user's machine", and nothing else in the build would catch +/// it. /// -/// 所以这道保障必须在这里补回来。`L10nKey` 是 CaseIterable,新加的文案 -/// 自动被覆盖,不需要手动登记。 +/// So the guarantee is restored here. `L10nKey` is CaseIterable, so new +/// messages are covered automatically; no manual registration. +/// +/// Cross-language parity checks return when a language is added: every table +/// must expose the same placeholder positions and conversions as the English +/// one (see the checklist at the top of messages.def). final class LocalizationTests: XCTestCase { - /// 一个替换字段:位置(显式 `%1$` 的编号,隐式则按出现顺序)+ 转换类型。 + /// One replacement field: position (the explicit `%1$` number; implicit + /// fields count in order of appearance) + conversion type. private struct Placeholder: Equatable, CustomStringConvertible { let position: Int let conversion: String @@ -24,10 +32,10 @@ final class LocalizationTests: XCTestCase { var description: String { "#\(position):%\(conversion)" } } - /// 解析 printf 风格的格式串。 + /// Parses a printf-style format string. /// - /// 只覆盖本表真正用到的子集:`%@`、`%ld`、`%lx`、`%f` 及其带宽度/精度/位置 - /// 的形式,外加 `%%` 转义。 + /// Covers only the subset the table really uses: `%@`, `%ld`, `%lx`, + /// `%f` and their width/precision/positional forms, plus the `%%` escape. private func placeholders(in text: String) -> [Placeholder] { var result: [Placeholder] = [] var automatic = 0 @@ -41,12 +49,12 @@ final class LocalizationTests: XCTestCase { } index += 1 guard index < characters.count else { break } - if characters[index] == "%" { // `%%` 转义 + if characters[index] == "%" { // `%%` escape index += 1 continue } - // 显式位置:`3$` + // Explicit position: `3$` var explicit: Int? var digits = "" var lookahead = index @@ -59,12 +67,13 @@ final class LocalizationTests: XCTestCase { index = lookahead + 1 } - // 标志、宽度、精度 —— 一概跳过,它们不影响实参类型。 + // Flags, width, precision -- all skipped, they do not affect the + // argument type. while index < characters.count, "0123456789.-+ #'".contains(characters[index]) { index += 1 } - // 长度修饰符(l / ll / h / z)属于类型的一部分,要留下。 + // Length modifiers (l / ll / h / z) are part of the type and stay. var conversion = "" while index < characters.count, "lhzqjt".contains(characters[index]) { conversion.append(characters[index]) @@ -80,50 +89,33 @@ final class LocalizationTests: XCTestCase { return result.sorted { $0.position < $1.position } } - func testEveryKeyHasBothLanguages() { + func testEveryKeyHasText() { for key in L10nKey.allCases { - let (chinese, english) = key.localizations - XCTAssertFalse(chinese.isEmpty, "\(key.rawValue) 缺中文") - XCTAssertFalse(english.isEmpty, "\(key.rawValue) 缺英文") + XCTAssertFalse(key.localizations.isEmpty, "\(key.rawValue) has no text") } } - /// 本文件的核心用例。 - func testPlaceholdersMatchAcrossLanguages() { + /// The core case. + func testPlaceholderPositionsAreContiguous() { for key in L10nKey.allCases { - let (chinese, english) = key.localizations - let chinesePlaceholders = placeholders(in: chinese) - let englishPlaceholders = placeholders(in: english) - - XCTAssertEqual( - chinesePlaceholders, englishPlaceholders, - """ - \(key.rawValue) 的占位符两种语言对不上: - 中文 \(chinesePlaceholders) — \(chinese) - 英文 \(englishPlaceholders) — \(english) - """) - - // 位置必须是连续的 1...n。留空档时 String(format:) 照样能渲染, - // 但那说明有个实参被两种语言同时忽略了,几乎总是写错。 - for (offset, placeholder) in chinesePlaceholders.enumerated() { + let parsed = placeholders(in: key.localizations) + for (offset, placeholder) in parsed.enumerated() { XCTAssertEqual(placeholder.position, offset + 1, - "\(key.rawValue) 的占位符编号不连续:\(chinesePlaceholders)") + "\(key.rawValue) placeholder positions have a gap: \(parsed)") } } } - /// 整数一律 `%ld`:`%d` 只取 64 位实参的低 32 位,Swift 侧传的是 `Int`。 + /// Integers always use `%ld`: `%d` takes only the low 32 bits of a 64-bit + /// argument, and Swift call sites pass Int. func testIntegerPlaceholdersUseLongModifier() { for key in L10nKey.allCases { - let (chinese, english) = key.localizations - for text in [chinese, english] { - for placeholder in placeholders(in: text) where - ["d", "i", "u", "x", "X"].contains(placeholder.conversion) { - XCTFail(""" - \(key.rawValue) 用了 %\(placeholder.conversion),应当是 \ - %l\(placeholder.conversion) —— 调用点传的是 Int(64 位)。 - """) - } + for placeholder in placeholders(in: key.localizations) where + ["d", "i", "u", "x", "X"].contains(placeholder.conversion) { + XCTFail(""" + \(key.rawValue) uses %\(placeholder.conversion); it should be \ + %l\(placeholder.conversion) -- call sites pass Int (64-bit). + """) } } } @@ -132,46 +124,43 @@ final class LocalizationTests: XCTestCase { let original = L10n.preference defer { L10n.apply(original) } - XCTAssertEqual(L10n.apply(.chinese), .chinese) - XCTAssertEqual(L10n.text(.ok), "好") - XCTAssertEqual(L10n.apply(.english), .english) XCTAssertEqual(L10n.text(.ok), "OK") - // `.system` 解析成两者之一,具体是哪个取决于运行测试的机器。 - XCTAssertTrue([Language.chinese, .english].contains(L10n.apply(.system))) + // .system walks the same machinery; with one shipped language it + // answers English for every locale input. + XCTAssertEqual(L10n.apply(.system), .english) + XCTAssertEqual(L10n.localeLanguage("zh_CN.UTF-8"), .english) + XCTAssertEqual(L10n.localeLanguage("en_US.UTF-8"), .english) + XCTAssertEqual(L10n.localeLanguage("C"), .english) } func testFormattingSubstitutesArguments() { let original = L10n.preference defer { L10n.apply(original) } - L10n.apply(.chinese) - XCTAssertTrue(L(.helperConnectFailed, "连不上").contains("连不上")) - L10n.apply(.english) - let english = L(.helperConnectFailed, "unreachable") - XCTAssertTrue(english.contains("unreachable")) - XCTAssertFalse(english.contains("%@"), "参数没被替换掉:\(english)") + let withArgument = L(.helperConnectFailed, "unreachable") + XCTAssertTrue(withArgument.contains("unreachable")) + XCTAssertFalse(withArgument.contains("%@"), "argument not substituted: \(withArgument)") - // 换语序的那几条要真的按位置替换,而不是按出现顺序。 + // The positional entries must substitute by position, not by order of + // appearance. let alias = L(.aliasCreated, in: .english, "/Applications/TetherKit", "/opt/TetherKit.app") XCTAssertTrue(alias.contains("/Applications/TetherKit")) XCTAssertTrue(alias.contains("/opt/TetherKit.app")) } - /// 语言标签与 C ABI 的 `tk_language_t` 对齐 —— 错位的话 GUI 是一种语言、 - /// 库日志是另一种,而且没有任何报错。 + /// Language tags must match the C ABI's tk_language_t -- if they drift, + /// the GUI speaks one language and library logs speak another, with no + /// error anywhere. func testCValueMatchesCABI() { XCTAssertEqual(Language.english.cValue, 0) - XCTAssertEqual(Language.chinese.cValue, 1) } - /// 语言菜单里的选项永远写母语名字,不跟随界面语言翻译。 - func testLanguageNamesAreNotTranslated() { - XCTAssertEqual(L10nKey.languageChinese.localizations.chinese, - L10nKey.languageChinese.localizations.english) - XCTAssertEqual(L10nKey.languageEnglish.localizations.chinese, - L10nKey.languageEnglish.localizations.english) + /// The language menu always shows options in their native name, never + /// translated through the current interface language. + func testLanguageNameIsNative() { + XCTAssertEqual(L10nKey.languageEnglish.localizations, "English") } } From ff98afc36d670402b6e3578acefc2e1305269b55 Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:53:42 +0530 Subject: [PATCH 3/6] feat(i18n)!: translate comments and benchmark output to English --- .clang-format | 15 +- .clang-tidy | 27 +- .gitignore | 14 +- AGENTS.md | 1 + CMakeLists.txt | 117 +-- benchmarks/CMakeLists.txt | 11 +- benchmarks/bench_common.cc | 112 +-- benchmarks/bench_common.h | 4 +- benchmarks/bench_main.cc | 48 +- benchmarks/bench_net.cc | 112 +-- benchmarks/bench_net.h | 28 +- benchmarks/bench_rndis.cc | 68 +- benchmarks/bench_rndis.h | 4 +- benchmarks/harness.cc | 39 +- benchmarks/harness.h | 83 ++- cmake/CompilerWarnings.cmake | 17 +- cmake/FindLibUSB.cmake | 25 +- cmake/Optimizations.cmake | 35 +- cmake/Sanitizers.cmake | 25 +- cmake/tetherkit_exports.txt | 17 +- gui/.clang-tidy | 8 +- gui/.gitignore | 2 +- gui/Package.swift | 107 +-- gui/Resources/App-Info.plist | 39 +- gui/Resources/com.tetherkit.helper.plist | 39 +- gui/Scripts/build-gui.sh | 198 ++--- gui/Scripts/install-helper.sh | 84 ++- gui/Scripts/uninstall-helper.sh | 22 +- gui/Sources/CTetherKit/shim.c | 18 +- gui/Sources/TetherKitApp/AppModel.swift | 478 +++++++----- gui/Sources/TetherKitApp/DesignSystem.swift | 82 ++- gui/Sources/TetherKitApp/FinderAlias.swift | 56 +- gui/Sources/TetherKitApp/HelperClient.swift | 62 +- .../TetherKitApp/HelperInstaller.swift | 159 ++-- gui/Sources/TetherKitApp/TetherKitApp.swift | 97 ++- gui/Sources/TetherKitApp/UpdateChecker.swift | 50 +- .../TetherKitApp/Views/ContentView.swift | 42 +- .../TetherKitApp/Views/DeviceCard.swift | 37 +- gui/Sources/TetherKitApp/Views/LogCard.swift | 3 +- .../TetherKitApp/Views/MenuBarPanel.swift | 45 +- .../TetherKitApp/Views/NetworkCard.swift | 64 +- .../TetherKitApp/Views/StatusHeroCard.swift | 25 +- .../TetherKitApp/Views/ThroughputCard.swift | 26 +- gui/Sources/TetherKitCore/CInterop.swift | 43 +- .../TetherKitCore/NetworkConfigurator.swift | 51 +- .../TetherKitCore/TetherKitError.swift | 17 +- .../TetherKitCore/TetherKitLibrary.swift | 56 +- .../TetherKitCore/TetherKitSession.swift | 39 +- .../TetherKitHelper/HelperService.swift | 133 ++-- .../TetherKitHelper/InstallerMode.swift | 47 +- gui/Sources/TetherKitHelper/main.swift | 74 +- gui/Sources/TetherKitIPC/Authorization.swift | 175 +++-- .../TetherKitIPC/HelperConstants.swift | 100 ++- gui/Sources/TetherKitIPC/HelperProtocol.swift | 96 ++- gui/Sources/TetherKitIPC/Models.swift | 140 ++-- .../AuthorizationTests.swift | 43 +- .../HelperConstantsTests.swift | 48 +- include/tetherkit/capi/.clang-tidy | 14 +- include/tetherkit/capi/tetherkit_c.h | 474 +++++++----- include/tetherkit/common/byte_order.h | 40 +- include/tetherkit/common/cache.h | 26 +- include/tetherkit/common/error.h | 80 +- include/tetherkit/common/frame_ring.h | 189 +++-- include/tetherkit/common/logging.h | 98 +-- include/tetherkit/common/scheduling.h | 52 +- include/tetherkit/common/spsc_ring.h | 151 ++-- include/tetherkit/common/stats.h | 73 +- include/tetherkit/common/time.h | 62 +- include/tetherkit/core/bridge.h | 218 +++--- include/tetherkit/core/runtime.h | 272 ++++--- include/tetherkit/net/bpf_link.h | 198 +++-- include/tetherkit/net/darwin_abi.h | 193 ++--- include/tetherkit/net/feth_device.h | 196 ++--- include/tetherkit/net/link_backend.h | 55 +- include/tetherkit/net/loopback_link.h | 71 +- include/tetherkit/rndis/control_channel.h | 109 +-- include/tetherkit/rndis/messages.h | 156 ++-- include/tetherkit/rndis/packet_codec.h | 162 ++-- include/tetherkit/rndis/protocol.h | 378 +++++----- include/tetherkit/rndis/state_machine.h | 330 +++++---- include/tetherkit/usb/context.h | 76 +- include/tetherkit/usb/data_channel.h | 275 ++++--- include/tetherkit/usb/device.h | 234 +++--- include/tetherkit/version.h | 20 +- src/CMakeLists.txt | 37 +- src/app/CMakeLists.txt | 11 +- src/app/main.cc | 116 +-- src/capi/.clang-tidy | 7 +- src/capi/CMakeLists.txt | 43 +- src/capi/capi_support.cc | 16 +- src/capi/capi_support.h | 122 +-- src/capi/core_foundation_support.cc | 14 +- src/capi/core_foundation_support.h | 41 +- src/capi/environment.cc | 94 ++- src/capi/log_ring.cc | 55 +- src/capi/net_config.cc | 176 +++-- src/capi/orphan_cleanup.cc | 78 +- src/capi/process_runner.cc | 30 +- src/capi/process_runner.h | 49 +- src/capi/session.cc | 78 +- src/common/CMakeLists.txt | 9 +- src/common/error.cc | 18 +- src/common/logging.cc | 64 +- src/common/scheduling.cc | 5 +- src/core/CMakeLists.txt | 7 +- src/core/bridge.cc | 188 +++-- src/core/runtime.cc | 197 +++-- src/net/CMakeLists.txt | 10 +- src/net/bpf_link.cc | 207 +++--- src/net/feth_device.cc | 96 ++- src/net/loopback_link.cc | 6 +- src/rndis/CMakeLists.txt | 9 +- src/rndis/messages.cc | 112 +-- src/rndis/protocol.cc | 3 +- src/rndis/state_machine.cc | 207 +++--- src/usb/CMakeLists.txt | 9 +- src/usb/context.cc | 30 +- src/usb/data_channel.cc | 169 +++-- src/usb/device.cc | 166 +++-- src/version.cc.in | 2 +- tests/CMakeLists.txt | 59 +- tests/mock_control_channel.h | 96 +-- tests/mock_data_channel.h | 55 +- tests/test_capi.cc | 671 ++++++++--------- tests/test_common_byte_order.cc | 123 ++-- tests/test_common_error.cc | 154 ++-- tests/test_common_frame_ring.cc | 525 ++++++------- tests/test_common_spsc_ring.cc | 184 ++--- tests/test_common_stats.cc | 219 +++--- tests/test_core_bridge.cc | 697 +++++++++--------- tests/test_main.cc | 7 +- tests/test_net_link.cc | 693 ++++++++--------- tests/test_rndis_messages.cc | 300 ++++---- tests/test_rndis_packet_codec.cc | 632 ++++++++-------- tests/test_rndis_state_machine.cc | 654 ++++++++-------- tests/test_version.cc | 20 +- third_party/CMakeLists.txt | 14 +- 137 files changed, 8689 insertions(+), 6704 deletions(-) diff --git a/.clang-format b/.clang-format index a0f4559..3321916 100644 --- a/.clang-format +++ b/.clang-format @@ -1,5 +1,6 @@ -# TetherKit 代码格式配置 -# 以 Google 风格为基线,放宽行宽到 100 列以容纳协议常量表与长类型名。 +# TetherKit code formatting configuration. +# Google style as the baseline, with line width relaxed to 100 columns to fit +# protocol constant tables and long type names. --- Language: Cpp BasedOnStyle: Google @@ -11,12 +12,12 @@ TabWidth: 2 UseTab: Never AccessModifierOffset: -1 -# 指针/引用贴类型:TetherKit 统一写 `Foo* p` 而非 `Foo *p` +# Pointer/reference hugs the type: TetherKit writes `Foo* p`, not `Foo *p` DerivePointerAlignment: false PointerAlignment: Left ReferenceAlignment: Left -# 大括号:函数与控制流的左括号不换行(Attach) +# Braces: function and control-flow opening braces stay on the same line (Attach) BreakBeforeBraces: Attach AllowShortFunctionsOnASingleLine: Inline AllowShortIfStatementsOnASingleLine: Never @@ -24,12 +25,12 @@ AllowShortLoopsOnASingleLine: false AllowShortCaseLabelsOnASingleLine: false InsertBraces: true -# 构造函数初始化列表每行一个,便于 diff +# Constructor initializer lists: one base per line to keep diffs readable BreakConstructorInitializers: BeforeColon ConstructorInitializerAllOnOneLineOrOnePerLine: true PackConstructorInitializers: NextLine -# 头文件分组:本项目头 > 第三方 > 系统/平台 > 标准库 +# Include-group order: project headers > third party > system/platform > stdlib SortIncludes: CaseSensitive IncludeBlocks: Regroup IncludeCategories: @@ -46,7 +47,7 @@ IncludeCategories: - Regex: '.*' Priority: 5 -# 其它 +# Misc AlwaysBreakTemplateDeclarations: Yes EmptyLineBeforeAccessModifier: LogicalBlock FixNamespaceComments: true diff --git a/.clang-tidy b/.clang-tidy index 93bac21..3f0efc2 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -1,12 +1,17 @@ -# TetherKit 静态检查配置 +# TetherKit static analysis configuration. # -# 取舍说明: -# * 本项目是与内核 ABI / USB 线格式打交道的底层驱动,必然出现大量 reinterpret_cast、 -# C 风格数组、位运算与魔数常量,因此关掉一批对系统编程不适用的检查。 -# * 数据路径要求零分配、无虚函数开销,因此保留 performance-* 组,但关掉 -# performance-enum-size —— 本项目的协议枚举底层类型**必须**精确等于线格式 -# 的字段宽度(RNDIS 全是 LE32),按检查建议缩小会直接写坏线格式。 -# * 并发正确性是本项目的核心风险,因此保留 concurrency-* 与 bugprone-* 全组。 +# Trade-offs: +# * This project is a low-level driver dealing with kernel ABIs and USB wire +# formats, so reinterpret_cast, C-style arrays, bit manipulation and magic +# constants are inevitable; that rules out a batch of checks that do not +# suit systems programming. +# * The data paths demand zero allocation and no virtual-call overhead, so the +# performance-* group stays on -- except performance-enum-size: the +# underlying type of a protocol enum **must** exactly match the wire-format +# field width (RNDIS is all LE32), and shrinking it as the check suggests +# would corrupt the wire format outright. +# * Concurrency correctness is the project's core risk, so the concurrency-* +# and bugprone-* groups stay on in full. --- Checks: > bugprone-*, @@ -52,7 +57,8 @@ HeaderFilterRegex: 'tetherkit/.*' FormatStyle: file CheckOptions: - # 命名约定(与 docs/DESIGN.md「代码规范」一节一致) + # Naming conventions (consistent with the "Code conventions" section of + # docs/DESIGN.md) - key: readability-identifier-naming.NamespaceCase value: lower_case - key: readability-identifier-naming.ClassCase @@ -89,6 +95,7 @@ CheckOptions: value: CamelCase - key: readability-identifier-naming.MacroDefinitionCase value: UPPER_CASE - # 数据路径禁止隐式堆分配,放宽 else-after-return 便于早退风格 + # The data path forbids implicit heap allocation; relaxing else-after-return + # keeps the early-return style usable - key: readability-else-after-return.WarnOnUnfixable value: 'false' diff --git a/.gitignore b/.gitignore index 0f674ed..a4017c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# ---- 构建产物 ---- +# ---- build artifacts ---- build/ build-*/ out/ @@ -15,7 +15,7 @@ Testing/ compile_commands.json _deps/ -# ---- 编译中间产物 ---- +# ---- compiler intermediates ---- *.o *.a *.so @@ -33,7 +33,7 @@ _deps/ .Spotlight-V100 .Trashes -# ---- 编辑器 / IDE ---- +# ---- editors / IDEs ---- .vscode/ .idea/ *.swp @@ -41,13 +41,13 @@ _deps/ .cache/ .clangd/ -# ---- GUI 打包产物 ---- +# ---- GUI packaging artifacts ---- # -# dist/ 由 gui/Scripts/build-gui.sh 生成(TetherKit.app + helper), -# 是纯派生物,不进版本库。 +# dist/ is produced by gui/Scripts/build-gui.sh (TetherKit.app + helper). +# It is a pure derivative and does not belong in version control. dist/ -# ---- 运行期产物 ---- +# ---- runtime artifacts ---- *.log *.pcap /benchmark-results/ diff --git a/AGENTS.md b/AGENTS.md index 0f2c4cb..b99bc9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -271,6 +271,7 @@ Swift 的 C++ 互操作吞不下,所以 C ABI 这一层不可省。 | 55 | `fix(ci): narrow codeql pr trigger, event-scope concurrency` | ✅ | Review round on row 54, five findings, four applied in codeql.yml plus this row's amendment: (1, consensus) pull_request paths narrowed `.github/**` -> `.github/workflows/**` - dependabot.yml and .github/scripts/ edits never reach the analyzer, while Dependabot's weekly grouped action-bump PR still matches by construction (it edits workflow files) and the header comment now states that ride-along as deliberate; (2, sharpest catch) the header's "No concurrency block: canceling would throw away the whole analysis" sentence predated the PR trigger and no longer held - a superseded PR commit's SARIF is simply replaced by the newer run - so an event-scoped concurrency group mirroring tests.yml landed (group `${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}`, cancel-in-progress: true) with a comment explaining why cancellation is safe within one event and why event_name in the key keeps a push to main from canceling the Monday cron; (3) row 54 amended (above) to cite verification runs 42578919426 and 42578945162, sealing the failure-plus-verification pairing the ledger's audit value depends on; (4, nit) the Build step comment now carries the hard-coded-on-purpose guard - under the tracer `uname -m` reports x86_64, so a `-DCMAKE_OSX_ARCHITECTURES=$(uname -m)` "simplification" would silently reintroduce the bug (the deliberate-choices-need-a-magnitude rule from pitfall 16); (5, INFO) the first-ever green analysis surfaced one pre-existing `cpp/use-after-free` at vendored third_party/doctest/doctest.h:3710 - resolved as a documented decline rather than config: `paths-ignore` is verifiably inert for C/C++ manual builds (codeql team statement in github/codeql-action#3890; GitHub docs confine paths filtering to build-mode none; the traced run would warn "path-based filtering not supported"), and SARIF post-filtering via advanced-security/filter-sarif means a new third-party action for exactly one benign vendored alert - the workflow header records the accepted baseline instead; the fork-PR-403 finding was retracted by the reviewers against current GitHub docs (SARIF upload on pull_request events is permitted under read-only fork tokens). Two disjoint antigravity/gemini-3.8-flash delegates applied the codeql.yml and AGENTS.md edits byte-exact within disjoint file scopes. Verification: the PR-triggered analysis run 42583242299 is green on 75634ad, and the new group's cancel-in-progress proved itself on this very push - the in-flight run 42582860934 for the superseded commit 84d8151 was canceled and replaced by the 75634ad run. | | 56 | `feat(i18n)!: drop the Chinese column, keep the single-language wiring` (this branch) | ✅ | Maintainer decision: English-only, but preserving the multi-language plumbing so adding a language stays a bounded five-touch change (checklist now lives at the top of messages.def). messages.def went 843 -> 529 lines via a string-aware perl splice (TETHERKIT_MESSAGE(id, zh, en) -> (id, en)), 310 records; guards on kept column: no Han, no full-width punctuation. i18n.h/i18n.cc rewritten in English: Language enum reduced to kEnglish=0, single kMessageTable, TextIn kept as the designed per-language dispatch point, ParseLanguage no longer accepts zh/Chinese (returns false, CLI rejects with new English message), LanguageFromLocaleString parked at "always English" with the prefix rule to return there, ContextSeparator fixed to ": ". C ABI signature-stable: TK_LANGUAGE_CHINESE removed; ToLanguage returns nullopt for unknown values so a caller still sending the old Chinese value 1 is ignored and stays in English; helper XPC setLanguage method and protocolRevision 3 untouched (still applied, inert). Four ScopedLanguage guard sites flipped kChinese->kEnglish with English expectations (bridge stats "queue depth", CONDIS reject "connection-oriented", keepalive fatal "keep-alive", MalformedReason "none"); test_net_link's sysctl-why bilingual loop collapsed to the single table. test_common_i18n.cc rewritten: per-string placeholder well-formedness, no mixed indexing, contiguous indices 0..n-1; the cross-language parity checks were retired and are documented to come back when a second language lands. CLI help no longer advertises zh. Verification: cmake -Werror build clean, ctest 23/23 suites pass, smokes green (--version; --help shows the reworded --lang lines; --lang zh --list errors in English; --lang en --list and LC_ALL=zh_CN --list render English). GUI side deliberately untouched this phase, so `swift test` still exercises bilingual Swift tables and must be green throughout; the Swift strip is the next commit. Splice/validation scripts live in the session temp dir (not in the repo). Untracked docs/icon_resources/ and draft_icons/ predate this work and were left alone. | | 57 | `feat(i18n)!: strip the Swift side to English-only, keep the add-a-language wiring` (this branch) | ✅ | Phase 2 complement to row 56. LocalizedStrings.swift went 743 -> 685 lines via a string-aware perl splice: every `localizations` switch row lost its Chinese first argument, with two guards - the dropped arg must contain Han or full-width punctuation OR be identical to the kept one (the languageEnglish native-name row needs the second rule), and the kept column must be Han- and fullwidth-free; kept spans are preserved byte-exact, including multi-line `+`-concatenation layouts (installer/update/permission strings); the file header and 42 MARK/comment lines went to English via an exact-line map; the `languageChinese` key is gone entirely (both Pickers hard-code `Text(verbatim: "English")` under the tag, so the key had no non-test consumers). Language enum is `.english` only with `cValue` constant 0; LanguagePreference loses `.chinese`, so a stale saved rawValue "chinese" decodes to nil and falls back to `.system` (graceful, symmetric with the C ABI ignoring unknown values); `L10n.apply` and `text(_:in:)` survive as the add-a-language dispatch points; new internal `L10n.localeLanguage` is parked always-English, exactly mirroring LanguageFromLocaleString on the C++ side, and `systemLanguage` still reads Locale.preferredLanguages through it; HelperService.setLanguage keeps its ignore-unknown semantics (an old App still sending "chinese" leaves the helper in English); both language menus now offer Follow system + English. **Swift constraint determined empirically** (tiny probe program): single-element labeled tuples are illegal - `cannot create a single-element tuple with an element label` - so the planned `(english: String)` one-member tuple is unrepresentable and rows are bare strings; when a language is added the property becomes a real labeled tuple and every case appends one labeled argument, the same way C++ rows `(id, en)` grow. LocalizationTests rewritten for the single table, still 7 cases: per-key placeholder contiguity, the %ld-integer rule, language resolution incl. localeLanguage, and formatting substitution; the cross-language placeholder/parity checks retire until a second language and the suite header says they return then. Verification: swift build green (3.3s, only the pre-existing libtetherkit dylib-version warning); swift test 30/30 - TetherKitIPCTests 17/17 (Authorization 2 + HelperConstants 8 + the 7 rewritten LocalizationTests) and TetherKitUIModelTests 13/13; the current-state snapshot below says "17 IPC + 7 UIModel = 24", which predates the sidebar test additions, not this work. Also folded in here: the row-56 typo fix (i18n.cci -> i18n.h). Splice/unwrap scripts live in the session temp dir. | +| 58 | `feat(i18n)!: translate every Chinese comment and user-facing benchmark string to English` (this branch) | ✅ | P3 = the comment-and-doc-string half of the English-only migration (P1 = C++ message table, P2 = Swift string table). Scope: ~100 files, every Han comment in `include/`, `src/`, `tests/`, `benchmarks/`, `gui/` plus the benchmark tool's own report output; `docs/` + `README` + `AGENTS.md` are P4. Work was delegated in disjoint batches through the T3 `delegate_task` tool on the three OpenCode models the maintainer named (iceman-free, minimax-m3.1-flash-free, mimo-v2.6-flash-free; MiMo hit a free-tier limit mid-run and its remainder was re-dispatched to MiniMax); every batch was verified before acceptance, and three were sent back or fixed by hand. Verification method, all in-session: a comment-only scope proof (comments stripped from HEAD and from the tree, then the code token streams compared -- byte-identical for every file except the deliberately allowed literals), a comment-line-count drop check (a translation must not shrink a file's prose), a repo-wide Han census, the CI lint gate reproduced locally (vendored `.github/scripts/clang-format-diff.py` over the changed hunks, base HEAD), a full `-Werror` build plus 23/23 ctest, and `swift build`/`swift test` for the GUI batches. Findings worth keeping: (1) the house style is **pure ASCII in comments** -- the earlier crew had already converted box-drawing to `+ - |` in `feth_device.h` and dropped the `star`/`warning` markers, so two batches that kept them were normalized afterwards (emphasis now lives in the prose); (2) the CI format gate is **line-scoped**, so touching a test title drags its whole `TEST_SUITE` block into scope and clang-format then wants it re-indented by 2 -- the Chinese-era test files had their cases at column 0 and were never format-clean; the reformat was applied with an invariant that the **code token stream** is unchanged (a re-indent or a re-wrap may move a line break, never a token) and that the **comment word stream** is unchanged (a long comment may be reflowed, never reworded); (3) `src/app/main.cc` had to have its include blocks reordered (project headers before system headers, per `.clang-format`) -- pre-existing baseline debt that only became visible once a comment above it changed; (4) `docs/BENCHMARKS.md` is **half generated**: its first part is the literal output of `tetherkit_bench`, so the tool's report strings had to be translated too (they are user-facing) and the document's generated part must use exactly the wording the tool now prints -- but the document must NOT be regenerated, because that would drop the root-measured feth/BPF numbers and the hand-written analysis; (5) the `device` fixture in `tests/test_capi.cc` was **swapped, not translated**: it is a byte-pattern fixture (two 3-byte UTF-8 characters) and became two U+20AC characters (the euro sign, 3 bytes each, 6 bytes total) with every byte count in the assertions preserved; (6) the two CI grep anchors in `tests/test_net_link.cc` (the `ReportSkip` format string and the `created {} <-> {}` message) are byte-exact and must stay so; (7) a *passing* test is not proof -- a log-text needle such as `find("libusb initialized")` must match the production message table exactly or the assertion passes vacuously; (8) the Chinese strings in `tests/test_common_error.cc` are **paired** (a context string and the assertion on it) and were translated on both sides with identical wording, which is why the brief for that batch had to be corrected before dispatch. The GUI language plumbing, the XPC protocol revision 3 and the C ABI are untouched by this phase | ### 当前状态 diff --git a/CMakeLists.txt b/CMakeLists.txt index aa46151..e3376da 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,62 +3,68 @@ cmake_minimum_required(VERSION 3.24) project( TetherKit VERSION 0.1.5 - DESCRIPTION "macOS 用户态 RNDIS 驱动:libusb + feth + BPF" + DESCRIPTION "macOS userspace RNDIS driver: libusb + feth + BPF" LANGUAGES CXX) # ------------------------------------------------------------------------------ -# 平台前置检查 +# Platform prerequisites # -# 本项目依赖 macOS 私有的 feth(if_fake) 虚拟网卡与 Darwin BPF,无法跨平台。 +# This project depends on the macOS-private feth (if_fake) virtual interface +# and Darwin BPF; it cannot be portable. # ------------------------------------------------------------------------------ if(NOT APPLE) - message(FATAL_ERROR "TetherKit 仅支持 macOS:依赖 feth(if_fake) 与 Darwin BPF。") + message(FATAL_ERROR "TetherKit supports macOS only: it depends on feth (if_fake) and Darwin BPF.") endif() # ------------------------------------------------------------------------------ -# 全局语言标准 +# Global language standard # -# 选 C++23 的理由(实测于 Apple clang 21 / macOS 26.5): -# * std::expected —— 初始化路径的错误传播主力,避免异常与错误码混用。 -# * std::byteswap —— RNDIS 线格式是小端,跨端序时需要显式转换。 +# Why C++23 (measured on Apple clang 21 / macOS 26.5): +# * std::expected -- the primary error-propagation mechanism on control and +# init paths, avoiding a mix of exceptions and error codes. +# * std::byteswap -- the RNDIS wire format is little-endian, so byte order +# flips need explicit conversion. # ------------------------------------------------------------------------------ set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) -# 部署目标: -# * 不主动设置,让 CMake 取宿主 macOS 版本 —— 否则会和 Homebrew 的 libusb -# (按宿主版本构建)产生 "building for macOS-13.3, but linking with dylib -# which was built for newer version" 链接告警。 -# * 但必须校验下限 13.3:libc++ 的 std::to_chars(std::format 浮点格式化的 -# 底层实现)带 availability 标注,更低的部署目标会直接编译失败。 +# Deployment target: +# * Do not set it proactively; let CMake take the host macOS version -- +# otherwise linking against Homebrew's libusb (built for the host version) +# produces the "building for macOS-13.3, but linking with dylib which was +# built for newer version" warning. +# * But the 13.3 floor must be enforced: libc++'s std::to_chars (the +# underlying implementation of std::format's float formatting) carries +# availability annotations, and anything lower simply fails to compile. set(TETHERKIT_MIN_MACOS_VERSION "13.3") if(CMAKE_OSX_DEPLOYMENT_TARGET AND CMAKE_OSX_DEPLOYMENT_TARGET VERSION_LESS TETHERKIT_MIN_MACOS_VERSION) message( FATAL_ERROR - "CMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET} 过低," - "至少需要 ${TETHERKIT_MIN_MACOS_VERSION}(libc++ 的 std::to_chars 带 availability 标注)。") + "CMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET} is too low; " + "at least ${TETHERKIT_MIN_MACOS_VERSION} is required (libc++ std::to_chars carries availability annotations).") endif() if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE "RelWithDebInfo" - CACHE STRING "构建类型" FORCE) + CACHE STRING "Build type" FORCE) endif() -# 始终导出 compile_commands.json,供 clangd / clang-tidy 使用。 +# Always export compile_commands.json, for clangd / clang-tidy. set(CMAKE_EXPORT_COMPILE_COMMANDS ON) -# 全局开启位置无关代码。 +# Position-independent code, globally. # -# 各功能模块都是静态库,但它们最终要被链进共享库 libtetherkit —— 非 PIC 的目标 -# 文件链不进 dylib。macOS/clang 上 PIC 本来就是默认,这里显式写出来是为了让 -# 「为什么必须 PIC」这件事在构建脚本里有据可查。 +# The feature modules are all static libraries, but they end up linked into +# the shared lib libtetherkit -- non-PIC object files cannot link into a +# dylib. On macOS/clang, PIC is the default anyway; spelling it out here keeps +# the "why is PIC mandatory" reasoning documented in the build script itself. set(CMAKE_POSITION_INDEPENDENT_CODE ON) -# 可执行文件与库统一输出到 build/bin、build/lib,方便脚本引用。 +# Keep executables and libraries under build/bin and build/lib for easy script reference. set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") @@ -66,31 +72,31 @@ set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") # ------------------------------------------------------------------------------ -# 构建选项 +# Build options # ------------------------------------------------------------------------------ -option(TETHERKIT_BUILD_TESTS "构建单元测试" ON) -option(TETHERKIT_BUILD_BENCHMARKS "构建性能基准" ON) -option(TETHERKIT_BUILD_SHARED_LIB "构建共享库 libtetherkit(GUI 与其它语言绑定需要)" ON) -option(TETHERKIT_WARNINGS_AS_ERRORS "把编译警告当作错误" OFF) -option(TETHERKIT_NATIVE_ARCH "为本机 CPU 微架构优化(-mcpu=native,产物不可移植)" OFF) -option(TETHERKIT_ENABLE_LTO "启用链接时优化(Release 类构建)" OFF) -option(TETHERKIT_ENABLE_ASAN "启用 AddressSanitizer" OFF) -option(TETHERKIT_ENABLE_UBSAN "启用 UndefinedBehaviorSanitizer" OFF) -option(TETHERKIT_ENABLE_TSAN "启用 ThreadSanitizer(验证无锁队列与线程模型)" OFF) +option(TETHERKIT_BUILD_TESTS "Build unit tests" ON) +option(TETHERKIT_BUILD_BENCHMARKS "Build performance benchmarks" ON) +option(TETHERKIT_BUILD_SHARED_LIB "Build shared lib libtetherkit (needed by the GUI and other language bindings)" ON) +option(TETHERKIT_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF) +option(TETHERKIT_NATIVE_ARCH "Optimize for the host CPU microarchitecture (-mcpu=native; binary not portable)" OFF) +option(TETHERKIT_ENABLE_LTO "Enable link-time optimization (Release-style builds)" OFF) +option(TETHERKIT_ENABLE_ASAN "Enable AddressSanitizer" OFF) +option(TETHERKIT_ENABLE_UBSAN "Enable UndefinedBehaviorSanitizer" OFF) +option(TETHERKIT_ENABLE_TSAN "Enable ThreadSanitizer (validates the lock-free queues and thread model)" OFF) include(CompilerWarnings) include(Sanitizers) include(Optimizations) # ------------------------------------------------------------------------------ -# 依赖 +# Dependencies # ------------------------------------------------------------------------------ find_package(LibUSB REQUIRED) # ------------------------------------------------------------------------------ -# 子目录 +# Subdirectories # -# 依赖方向严格单向:common <- rndis/net <- usb <- core <- app +# The dependency direction is strictly one-way: common <- rndis/net <- usb <- core <- app # ------------------------------------------------------------------------------ add_subdirectory(third_party) add_subdirectory(src) @@ -105,26 +111,29 @@ if(TETHERKIT_BUILD_BENCHMARKS) endif() # ------------------------------------------------------------------------------ -# 图形界面 +# Graphical interface # -# Swift 部分由 SwiftPM 构建(见 gui/Package.swift 里为什么不用 CMake 的 Swift -# 支持),这里只提供一个方便的入口,把「先建 C++ 再建 Swift」串起来: +# The Swift part is built by SwiftPM (see gui/Package.swift for why CMake's +# Swift support is not used); this only provides a convenient entry point that +# chains "build C++ first, then Swift": # # cmake --build build --target gui # -# 刻意做成**手动触发**的目标(EXCLUDE_FROM_ALL 的效果):GUI 需要 Xcode 工具链, -# 而只想要命令行的人不该被迫装它。 +# Deliberately a **manual** target (the effect of EXCLUDE_FROM_ALL): the GUI +# needs the Xcode toolchain, and someone who only wants the command line should +# not be forced to install it. # ------------------------------------------------------------------------------ if(TETHERKIT_BUILD_SHARED_LIB AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/gui/Package.swift") - # 显式把当前构建目录传给脚本(它默认找 /build),这样在 - # build-rel / build-tsan 之类的非默认目录里也能正确找到 libtetherkit。 + # Pass the current build directory to the script explicitly (by default it + # looks for /build), so libtetherkit is also found correctly in + # non-default build dirs such as build-rel / build-tsan. add_custom_target( gui COMMAND "${CMAKE_COMMAND}" -E env "TETHERKIT_BUILD_DIR=${CMAKE_BINARY_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/gui/Scripts/build-gui.sh" WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" DEPENDS tetherkit_shared - COMMENT "构建 TetherKit.app 与 tetherkit-helper" + COMMENT "Building TetherKit.app and tetherkit-helper" USES_TERMINAL VERBATIM) set_property(TARGET gui APPEND PROPERTY @@ -132,23 +141,23 @@ if(TETHERKIT_BUILD_SHARED_LIB AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/gui/Packag endif() # ------------------------------------------------------------------------------ -# 构建配置摘要 +# Build configuration summary # ------------------------------------------------------------------------------ message(STATUS "") -message(STATUS "==== TetherKit ${PROJECT_VERSION} 构建配置 ====") -message(STATUS " 构建类型 : ${CMAKE_BUILD_TYPE}") -message(STATUS " C++ 标准 : C++${CMAKE_CXX_STANDARD}") +message(STATUS "==== TetherKit ${PROJECT_VERSION} build configuration ====") +message(STATUS " Build type : ${CMAKE_BUILD_TYPE}") +message(STATUS " C++ standard : C++${CMAKE_CXX_STANDARD}") if(CMAKE_OSX_DEPLOYMENT_TARGET) set(_tk_deploy "macOS ${CMAKE_OSX_DEPLOYMENT_TARGET}") else() - set(_tk_deploy "宿主默认") + set(_tk_deploy "host default") endif() -message(STATUS " 部署目标 : ${_tk_deploy}(下限 ${TETHERKIT_MIN_MACOS_VERSION})") -message(STATUS " 编译器 : ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}") +message(STATUS " Deployment target : ${_tk_deploy} (floor ${TETHERKIT_MIN_MACOS_VERSION})") +message(STATUS " Compiler : ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}") message(STATUS " libusb : ${LibUSB_VERSION} (${LibUSB_LIBRARY})") -message(STATUS " 单元测试 : ${TETHERKIT_BUILD_TESTS}") -message(STATUS " 性能基准 : ${TETHERKIT_BUILD_BENCHMARKS}") -message(STATUS " 本机微架构优化 : ${TETHERKIT_NATIVE_ARCH}") +message(STATUS " Unit tests : ${TETHERKIT_BUILD_TESTS}") +message(STATUS " Benchmarks : ${TETHERKIT_BUILD_BENCHMARKS}") +message(STATUS " Host microarch : ${TETHERKIT_NATIVE_ARCH}") message(STATUS " LTO : ${TETHERKIT_ENABLE_LTO}") message(STATUS " ASan/UBSan/TSan : ${TETHERKIT_ENABLE_ASAN}/${TETHERKIT_ENABLE_UBSAN}/${TETHERKIT_ENABLE_TSAN}") message(STATUS "=========================================") diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index 7b101e0..d49a246 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -1,8 +1,9 @@ -# benchmarks —— 性能基准 +# benchmarks -- performance benchmarks # -# 不引入 google/benchmark:本项目只需要「每帧纳秒数 / pps / Gbps」这三类指标, -# 自带的轻量 harness(见 benchmarks/harness.h)足够,且能直接输出 Markdown -# 汇总表写进 docs/BENCHMARKS.md,省掉一层解析。 +# google/benchmark is not introduced: this project only needs three kinds of metrics -- +# "nanoseconds per frame / pps / Gbps". The lightweight harness bundled here (see +# benchmarks/harness.h) is sufficient and can directly output a Markdown summary table to +# docs/BENCHMARKS.md, saving a parsing layer. add_executable( tetherkit_bench @@ -12,6 +13,6 @@ add_executable( bench_rndis.cc harness.cc) -# tk_core PUBLIC 链接了其余全部模块(基准要用到 rndis 编解码与 net loopback)。 +# tk_core PUBLIC-links all other modules (the benchmarks need the RNDIS codec and net loopback). target_link_libraries(tetherkit_bench PRIVATE tetherkit_base tk_version tk_core) target_include_directories(tetherkit_bench PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") diff --git a/benchmarks/bench_common.cc b/benchmarks/bench_common.cc index 8d85780..936ab55 100644 --- a/benchmarks/bench_common.cc +++ b/benchmarks/bench_common.cc @@ -1,9 +1,11 @@ -// 基础设施层的微基准。 +// Micro-benchmarks for the infrastructure layer. // -// 这些数字是后续判断「哪里是真瓶颈」的基线。特别是: -// * memcpy 一帧 vs 一次 SPSC 入队出队 —— 用来验证 FrameRing 注释里那个 -// 「拷贝不是瓶颈」的论断; -// * 无锁队列的跨线程吞吐 —— 决定数据路径的理论上限。 +// These numbers are the baseline for later judging "where the real bottleneck is". In +// particular: +// * memcpy of one frame vs one SPSC enqueue/dequeue -- used to verify the claim in the +// FrameRing comments that "copying is not the bottleneck"; +// * Cross-thread throughput of the lock-free queue -- determines the theoretical ceiling +// of the data path. #include "bench_common.h" #include @@ -24,7 +26,7 @@ namespace tetherkit::bench { namespace { -/// 以太网上最常见的两种帧长:MTU 满帧与 TCP ACK 小帧。 +/// The two most common frame lengths on Ethernet: a full MTU frame and a small TCP ACK frame. constexpr std::uint32_t kFullFrameBytes = 1514; constexpr std::uint32_t kSmallFrameBytes = 64; @@ -37,14 +39,15 @@ std::vector MakeFrame(std::uint32_t length) { } // --------------------------------------------------------------------------- -// memcpy 基线 +// memcpy baseline // --------------------------------------------------------------------------- -/// 纯 memcpy:FrameRing 单次拷贝成本的下界。 +/// Pure memcpy: the lower bound of a single-copy cost in FrameRing. /// -/// 注意这是**全热缓存**下的数字:源与目标都常驻 L1。真实 RX 路径的源是 USB -/// DMA 写入的缓冲,对 CPU 缓存是冷的,实际成本会更高。这里量的是下界, -/// 用来和 FrameRing 的往返成本相减,估算队列本身的开销。 +/// Note this is a number under fully hot caches: source and destination both stay in L1. +/// In the real RX path the source is a buffer written by USB DMA, which is cold for the CPU +/// cache, so the actual cost is higher. This measures the lower bound, used to subtract from +/// FrameRing's round-trip cost to estimate the queue's own overhead. std::uint64_t BenchMemcpy(std::uint64_t iterations, std::uint32_t frame_bytes) { const std::vector source = MakeFrame(frame_bytes); std::vector destination(frame_bytes); @@ -56,13 +59,13 @@ std::uint64_t BenchMemcpy(std::uint64_t iterations, std::uint32_t frame_bytes) { } // --------------------------------------------------------------------------- -// 字节序读写 +// Byte-order reads and writes // --------------------------------------------------------------------------- -/// 解析一个 RNDIS_PACKET_MSG 头部所需的 11 次 LoadLe32。 +/// 11 LoadLe32 calls needed to parse an RNDIS_PACKET_MSG header. std::uint64_t BenchParsePacketHeader(std::uint64_t iterations) { - // 44 字节的 REMOTE_NDIS_PACKET_MSG 头部,刻意放在奇数偏移上, - // 顺便验证未对齐读取的代价(应该为零)。 + // A 44-byte REMOTE_NDIS_PACKET_MSG header, deliberately placed at an odd offset to also + // verify the cost of unaligned reads (which should be zero). std::vector buffer(64); for (std::size_t i = 0; i < buffer.size(); ++i) { buffer[i] = std::byte{static_cast(i)}; @@ -70,7 +73,7 @@ std::uint64_t BenchParsePacketHeader(std::uint64_t iterations) { std::uint32_t accumulator = 0; for (std::uint64_t i = 0; i < iterations; ++i) { - const std::byte* header = buffer.data() + 1; // 未对齐 + const std::byte* header = buffer.data() + 1; // unaligned for (std::size_t field = 0; field < 11; ++field) { accumulator += LoadLe32(header + field * 4); } @@ -80,10 +83,11 @@ std::uint64_t BenchParsePacketHeader(std::uint64_t iterations) { } // --------------------------------------------------------------------------- -// SPSC 队列 +// SPSC queue // --------------------------------------------------------------------------- -/// 单线程入队+出队一轮:测纯索引运算与内存序开销(无跨核缓存往返)。 +/// One enqueue+dequeue round in a single thread: measures pure index arithmetic and memory-order +/// overhead (no cross-core cache round trips). std::uint64_t BenchSpscRoundTripSameThread(std::uint64_t iterations) { SpscRing ring(1024); std::uint64_t sink = 0; @@ -99,7 +103,8 @@ std::uint64_t BenchSpscRoundTripSameThread(std::uint64_t iterations) { return iterations; } -/// 跨线程搬运:这是数据路径真正会遇到的情形,包含缓存一致性往返。 +/// Cross-thread transfer: the situation the data path actually encounters, including +/// cache-coherency round trips. std::uint64_t BenchSpscCrossThread(std::uint64_t iterations) { SpscRing ring(4096); std::atomic start{false}; @@ -133,7 +138,7 @@ std::uint64_t BenchSpscCrossThread(std::uint64_t iterations) { // FrameRing // --------------------------------------------------------------------------- -/// 单线程「预留-写入-提交-读取-释放」一轮,含一次 memcpy。 +/// One "reserve-write-commit-read-release" round in a single thread, including one memcpy. std::uint64_t BenchFrameRingRoundTrip(std::uint64_t iterations, std::uint32_t frame_bytes) { FrameRing ring(1024, kMaxEthernetFrameBytes); const std::vector source = MakeFrame(frame_bytes); @@ -156,11 +161,13 @@ std::uint64_t BenchFrameRingRoundTrip(std::uint64_t iterations, std::uint32_t fr return iterations; } -/// 跨线程帧搬运:RX 路径(libusb 回调线程 → BPF 写线程)的真实模型。 +/// Cross-thread frame transfer: the real model of the RX path (libusb callback thread -> BPF +/// write thread). /// -/// `batch_size` = 1 时等价于逐帧发布(每帧一次 release store,写位置所在缓存行 -/// 每帧跨核弹一次);> 1 时用 BatchWrite / BatchRead 把发布摊薄。 -/// 这一组对比是整个数据路径设计里收益最高的单点优化的直接证据。 +/// `batch_size` = 1 is equivalent to per-frame publishing (one release store per frame, so +/// the cache line holding the write position bounces across cores once per frame); > 1 uses +/// BatchWrite / BatchRead to amortize publishing. This comparison is direct evidence for the +/// highest-payoff single optimization in the entire data-path design. std::uint64_t BenchFrameRingCrossThread(std::uint64_t iterations, std::uint32_t frame_bytes, std::uint32_t batch_size) { FrameRing ring(4096, kMaxEthernetFrameBytes); @@ -183,7 +190,7 @@ std::uint64_t BenchFrameRingCrossThread(std::uint64_t iterations, std::uint32_t batch.Commit(frame_bytes); ++sent; } - // batch 析构 → 一次 PublishWrite(n) + // batch destructor -> one PublishWrite(n) } }); @@ -196,26 +203,29 @@ std::uint64_t BenchFrameRingCrossThread(std::uint64_t iterations, std::uint32_t if (view.Empty()) { break; } - // 模拟消费者会真的读到帧内容(BPF write 会读整帧)。 + // Simulate the consumer actually reading the frame contents (BPF write reads the whole + // frame). DoNotOptimize(view.data[0]); ++received; } - // batch 析构 → 一次 PublishRead(n) + // batch destructor -> one PublishRead(n) } producer.join(); return iterations; } // --------------------------------------------------------------------------- -// 统计计数器 +// Statistics counters // --------------------------------------------------------------------------- -/// 每帧都要执行的计数器更新:必须便宜到可以忽略。 +/// Counter update executed for every frame: must be cheap enough to ignore. /// -/// 循环体里必须放 ClobberMemory():relaxed 原子的连续读-改-写是**允许**被编译器 -/// 合并的(N 次 +1 折叠成一次 +N),不加屏障测出来会是 0 ns/op —— 那不是计数器 -/// 真实的单次成本。真实数据路径上每两次计数之间都夹着一次系统调用,不存在合并 -/// 机会,所以加编译屏障(无运行时指令)才是正确的模型。 +/// ClobberMemory() is required in the loop body: consecutive read-modify-write operations on +/// relaxed atomics are allowed to be merged by the compiler (N times +1 folded into one +N); +/// without a barrier this would measure 0 ns/op -- which is not the counter's real per-call +/// cost. On the real data path every two counter updates are separated by a system call, so +/// there is no opportunity to merge; adding a compiler barrier (no runtime instructions) is +/// the correct model. std::uint64_t BenchCounterUpdate(std::uint64_t iterations) { DirectionCounters counters; for (std::uint64_t i = 0; i < iterations; ++i) { @@ -226,7 +236,8 @@ std::uint64_t BenchCounterUpdate(std::uint64_t iterations) { return iterations; } -/// 对比:如果用 fetch_add(真原子读-改-写,arm64 上是 LSE 的 ldadd)会贵多少。 +/// Comparison: how much more expensive fetch_add would be (a true atomic read-modify-write, +/// ldadd with LSE on arm64). std::uint64_t BenchCounterUpdateFetchAdd(std::uint64_t iterations) { std::atomic frames{0}; std::atomic bytes{0}; @@ -245,48 +256,51 @@ void RegisterCommonBenchmarks(Runner& runner) { constexpr std::uint64_t kMicroOps = 2'000'000; constexpr std::uint64_t kFrameOps = 500'000; - runner.Add("memcpy 基线", "1514 字节帧", + runner.Add("memcpy baseline", "1514-byte frame", Config{.ops_per_round = kFrameOps, .bytes_per_op = kFullFrameBytes}, [](std::uint64_t n) { return BenchMemcpy(n, kFullFrameBytes); }); - runner.Add("memcpy 基线", "64 字节帧", + runner.Add("memcpy baseline", "64-byte frame", Config{.ops_per_round = kFrameOps, .bytes_per_op = kSmallFrameBytes}, [](std::uint64_t n) { return BenchMemcpy(n, kSmallFrameBytes); }); - runner.Add("字节序", "解析 44 字节 RNDIS 包头(未对齐)", Config{.ops_per_round = kMicroOps}, + runner.Add("Byte order", "Parse 44-byte RNDIS header (unaligned)", + Config{.ops_per_round = kMicroOps}, [](std::uint64_t n) { return BenchParsePacketHeader(n); }); - runner.Add("SPSC 队列", "单线程 push+pop", Config{.ops_per_round = kMicroOps}, + runner.Add("SPSC queue", "single-thread push+pop", Config{.ops_per_round = kMicroOps}, [](std::uint64_t n) { return BenchSpscRoundTripSameThread(n); }); - runner.Add("SPSC 队列", "跨线程搬运", Config{.measure_rounds = 5, .ops_per_round = kFrameOps}, + runner.Add("SPSC queue", "cross-thread transfer", + Config{.measure_rounds = 5, .ops_per_round = kFrameOps}, [](std::uint64_t n) { return BenchSpscCrossThread(n); }); - runner.Add("FrameRing", "单线程往返(1514 字节)", + runner.Add("FrameRing", "single-thread round trip (1514 bytes)", Config{.ops_per_round = kFrameOps, .bytes_per_op = kFullFrameBytes}, [](std::uint64_t n) { return BenchFrameRingRoundTrip(n, kFullFrameBytes); }); - runner.Add("FrameRing", "单线程往返(64 字节)", + runner.Add("FrameRing", "single-thread round trip (64 bytes)", Config{.ops_per_round = kFrameOps, .bytes_per_op = kSmallFrameBytes}, [](std::uint64_t n) { return BenchFrameRingRoundTrip(n, kSmallFrameBytes); }); - // 批量发布的效果对比。这是整个数据路径最重要的一组数字:同一条队列、 - // 同样的搬运量,只改「每次 release store 发布多少帧」。 + // Comparison of batch-publish effects. This is the most important set of numbers in the + // entire data path: same queue, same transfer volume, changing only "how many frames per + // release store publish". for (const std::uint32_t batch : {1U, 4U, 8U, 32U, 64U}) { runner.Add( - "FrameRing 批量发布", - std::format("跨线程 1514 字节 × batch={}", batch), + "FrameRing batch publish", std::format("cross-thread 1514 bytes x batch={}", batch), Config{.measure_rounds = 5, .ops_per_round = kFrameOps, .bytes_per_op = kFullFrameBytes}, [batch](std::uint64_t n) { return BenchFrameRingCrossThread(n, kFullFrameBytes, batch); }); } runner.Add( - "FrameRing 批量发布", "跨线程 64 字节 × batch=1", + "FrameRing batch publish", "cross-thread 64 bytes x batch=1", Config{.measure_rounds = 5, .ops_per_round = kFrameOps, .bytes_per_op = kSmallFrameBytes}, [](std::uint64_t n) { return BenchFrameRingCrossThread(n, kSmallFrameBytes, 1); }); runner.Add( - "FrameRing 批量发布", "跨线程 64 字节 × batch=32", + "FrameRing batch publish", "cross-thread 64 bytes x batch=32", Config{.measure_rounds = 5, .ops_per_round = kFrameOps, .bytes_per_op = kSmallFrameBytes}, [](std::uint64_t n) { return BenchFrameRingCrossThread(n, kSmallFrameBytes, 32); }); - runner.Add("统计计数器", "relaxed load+store(本项目做法)", Config{.ops_per_round = kMicroOps}, + runner.Add("Statistics counters", "relaxed load+store (this project)", + Config{.ops_per_round = kMicroOps}, [](std::uint64_t n) { return BenchCounterUpdate(n); }); - runner.Add("统计计数器", "fetch_add 对比", Config{.ops_per_round = kMicroOps}, + runner.Add("Statistics counters", "fetch_add comparison", Config{.ops_per_round = kMicroOps}, [](std::uint64_t n) { return BenchCounterUpdateFetchAdd(n); }); } diff --git a/benchmarks/bench_common.h b/benchmarks/bench_common.h index b4ef76c..c821d22 100644 --- a/benchmarks/bench_common.h +++ b/benchmarks/bench_common.h @@ -1,11 +1,11 @@ -// 基础设施层微基准的注册入口。 +// Registration entry point for infrastructure-layer micro-benchmarks. #pragma once namespace tetherkit::bench { class Runner; -/// 把 tk_common 的全部微基准注册到 runner 上。 +/// Registers all tk_common micro-benchmarks with the runner. void RegisterCommonBenchmarks(Runner& runner); } // namespace tetherkit::bench diff --git a/benchmarks/bench_main.cc b/benchmarks/bench_main.cc index f3b4935..667b4f9 100644 --- a/benchmarks/bench_main.cc +++ b/benchmarks/bench_main.cc @@ -1,11 +1,11 @@ -// 性能基准入口。 +// Performance benchmark entry point. // -// 输出是一份完整的 Markdown 文档(标准输出),可以直接重定向进 -// docs/BENCHMARKS.md: +// The output is a complete Markdown document (stdout) that can be redirected directly into +// docs/BENCHMARKS.md: // // ./build-rel/bin/tetherkit_bench > docs/BENCHMARKS.md // -// 进度信息走标准错误,不会污染报告。 +// Progress messages go to stderr and do not pollute the report. #include #include #include @@ -18,47 +18,51 @@ #include "tetherkit/version.h" int main() { - // 基准线程本身也按数据路径的 QoS 运行,否则可能被调度到效率核, - // 测出来的数字会比实际运行时更差。 + // The benchmark thread itself runs at data-path QoS; otherwise it may be scheduled onto an + // efficiency core and the measured numbers would be worse than at actual runtime. tetherkit::ConfigureCurrentThread("bench", tetherkit::ThreadRole::kDataPath); const std::string version{tetherkit::GetVersionString()}; - std::printf("# TetherKit 性能基准\n\n"); - std::printf("由 `tetherkit_bench` 自动生成 —— %s\n\n", version.c_str()); - std::printf("> 重新生成:`./build-rel/bin/tetherkit_bench > docs/BENCHMARKS.md`\n"); - std::printf("> 必须使用 **未启用消毒器的 Release 构建**,否则数字无参考价值。\n\n"); + std::printf("# TetherKit Performance Benchmarks\n\n"); + std::printf("Automatically generated by `tetherkit_bench` -- %s\n\n", version.c_str()); + std::printf("> Regenerate: `./build-rel/bin/tetherkit_bench > docs/BENCHMARKS.md`\n"); + std::printf( + "> Must use a **Release build without sanitizers**, otherwise the numbers are not " + "meaningful.\n\n"); tetherkit::bench::PrintEnvironment(); - std::fprintf(stderr, "开始测量……\n"); + std::fprintf(stderr, "Starting measurements...\n"); { tetherkit::bench::Runner runner; tetherkit::bench::RegisterCommonBenchmarks(runner); - tetherkit::bench::PrintMarkdownReport(runner.RunAll(), "基础设施层(tk_common)"); + tetherkit::bench::PrintMarkdownReport(runner.RunAll(), "Infrastructure (tk_common)"); } { tetherkit::bench::Runner runner; tetherkit::bench::RegisterRndisBenchmarks(runner); - tetherkit::bench::PrintMarkdownReport(runner.RunAll(), "RNDIS 编解码(tk_rndis)"); + tetherkit::bench::PrintMarkdownReport(runner.RunAll(), "RNDIS encode/decode (tk_rndis)"); } { - // 链路层需要 root。跳过时也要在报告里留痕 —— 否则读者无从判断这份报告 - // 到底是「测过了没问题」还是「压根没测」。 + // The link layer requires root. When skipped, leave a trace in the report -- otherwise + // readers cannot tell whether this report "measured it and found no problem" or "never + // measured it at all". tetherkit::bench::Runner runner; std::string skip_reason; if (tetherkit::bench::RegisterNetBenchmarks(runner, skip_reason)) { - tetherkit::bench::PrintMarkdownReport(runner.RunAll(), "feth / BPF 链路层(tk_net,需 root)"); - // 必须在这里拆,不能留给静态析构 —— 原因见 bench_net.h。 + tetherkit::bench::PrintMarkdownReport(runner.RunAll(), + "feth / BPF link layer (tk_net, requires root)"); + // Must be torn down here, not left to static destruction -- see bench_net.h for the reason. tetherkit::bench::ShutdownNetBenchmarks(); } else { - std::printf("## feth / BPF 链路层(tk_net,需 root)\n\n"); - std::printf("**本次未测量** —— %s。\n\n", skip_reason.c_str()); - std::printf("补测方法:`sudo ./build/bin/tetherkit_bench > docs/BENCHMARKS.md`\n\n"); - std::fprintf(stderr, " 跳过链路层基准:%s\n", skip_reason.c_str()); + std::printf("## feth / BPF link layer (tk_net, requires root)\n\n"); + std::printf("**Not measured in this run** -- %s.\n\n", skip_reason.c_str()); + std::printf("To measure: `sudo ./build/bin/tetherkit_bench > docs/BENCHMARKS.md`\n\n"); + std::fprintf(stderr, " Skipping link-layer benchmarks: %s\n", skip_reason.c_str()); } } - std::fprintf(stderr, "测量完成。\n"); + std::fprintf(stderr, "Measurements complete.\n"); return 0; } diff --git a/benchmarks/bench_net.cc b/benchmarks/bench_net.cc index 460642d..0efa9cd 100644 --- a/benchmarks/bench_net.cc +++ b/benchmarks/bench_net.cc @@ -1,18 +1,20 @@ -// feth / BPF 链路层的基准 —— **需要 root**。 +// Benchmarks for the feth / BPF link layer -- requires root. // -// 这一组补的是 docs/BENCHMARKS.md 末节里长期挂着的那几项:BPF 写入的真实成本、 -// BIOCSBATCHWRITE 相对逐帧写的提速倍数、批量读取的每帧成本、以及 feth 数据路径的 -// 往返延迟。在此之前这些数字全部来自调研推算(那个 ~700 ns 的 write 成本来自 -// `write(/dev/null)`,**不是**真实 BPF 描述符上的测量)。 +// This group fills in the items long left open at the end of docs/BENCHMARKS.md: the real +// cost of BPF writes, the speedup of BIOCSBATCHWRITE over per-frame writes, the per-frame +// cost of batched reads, and the round-trip latency of the feth data path. Before this, +// these numbers all came from research estimates (that ~700 ns write cost came from +// `write(/dev/null)`, not a measurement on a real BPF descriptor). // -// 夹具的搭法: -// feth0(系统侧,配 10.99.99.1)←→ feth1(驱动侧) -// * driver_perframe / driver_batched:两个都挂在 feth1 上的 BPF 描述符, -// 分别关掉与打开 BIOCSBATCHWRITE,用来做逐帧写 vs 批量写的 A/B; -// * system_link:挂在 feth0 上,用来往 feth1 方向灌流量以测读取成本。 +// Fixture layout: +// feth0 (system side, assigned 10.99.99.1) <-> feth1 (driver side) +// * driver_perframe / driver_batched: two BPF descriptors both attached to feth1, with +// BIOCSBATCHWRITE disabled and enabled respectively, for a per-frame vs batched write A/B; +// * system_link: attached to feth0, used to inject traffic toward feth1 to measure read cost. // -// ⚠️ 写入成本天然包含「对侧 IP 栈把帧收下并丢弃」的开销 —— 那正是真实 RX 路径 -// 每帧都要付的代价,所以这么测才对,不该刻意绕开。 +// Write cost inherently includes the overhead of "the peer IP stack receiving and dropping +// the frame" -- that is exactly the cost the real RX path pays per frame, so measuring it +// this way is correct and should not be deliberately avoided. #include "bench_net.h" #include @@ -42,11 +44,11 @@ namespace { constexpr std::uint32_t kFullFrameBytes = 1514; constexpr std::uint32_t kSmallFrameBytes = 64; -/// 夹具用的地址。10.99.99/24 几乎不会与真实网络冲突。 +/// Address used by the fixture. 10.99.99/24 is unlikely to conflict with real networks. constexpr const char* kSystemIp = "10.99.99.1"; constexpr const char* kProbeIp = "10.99.99.2"; -/// ARP 帧内的字段偏移(帧首起算)。 +/// Field offsets within an ARP frame (counted from the start of the frame). constexpr std::size_t kEtherTypeOffset = 12; constexpr std::size_t kArpOperationOffset = 20; constexpr std::size_t kArpSenderIpOffset = 28; @@ -54,8 +56,9 @@ constexpr std::size_t kArpFrameBytes = 42; std::vector MakeFrame(std::uint32_t length) { std::vector frame(length, std::byte{0}); - // 广播目的 MAC + 本地管理源 MAC,EtherType 填 0x0800。内容本身不重要, - // 重要的是它是一帧结构合法、对侧会真的收下再丢掉的以太帧。 + // Broadcast destination MAC + locally administered source MAC, EtherType set to 0x0800. + // The contents themselves do not matter; what matters is that it is a structurally valid + // Ethernet frame that the peer will actually receive and then drop. for (int i = 0; i < 6; ++i) { frame[static_cast(i)] = std::byte{0xFF}; } @@ -67,7 +70,7 @@ std::vector MakeFrame(std::uint32_t length) { return frame; } -/// 造一个「谁是 kSystemIp」的 ARP 请求,用来测往返延迟。 +/// Builds an ARP request for "who has kSystemIp", used to measure round-trip latency. std::vector MakeArpRequest() { std::vector frame(kArpFrameBytes, std::byte{0}); const auto put = [&frame](std::size_t offset, std::initializer_list bytes) { @@ -108,7 +111,7 @@ std::vector MakeArpRequest() { return std::memcmp(view.data + kArpSenderIpOffset, &expected, sizeof(expected)) == 0; } -/// 给接口配 IPv4 地址(SIOCAIFADDR)。成功返回空串。 +/// Assigns an IPv4 address to the interface (SIOCAIFADDR). Returns an empty string on success. [[nodiscard]] std::string AssignIpv4(std::string_view interface_name, const char* address) { const int fd = ::socket(AF_INET, SOCK_DGRAM, 0); if (fd < 0) { @@ -130,7 +133,7 @@ std::vector MakeArpRequest() { }; if (!fill(request.ifra_addr, address) || !fill(request.ifra_mask, "255.255.255.0")) { ::close(fd); - return "inet_pton 失败"; + return "inet_pton failed"; } const int result = ::ioctl(fd, SIOCAIFADDR, &request); const int saved_errno = errno; @@ -138,20 +141,21 @@ std::vector MakeArpRequest() { return result == 0 ? std::string{} : Error::FromErrno(saved_errno, "ioctl(SIOCAIFADDR)").ToString(); } -/// 基准夹具。生命周期由 RegisterNetBenchmarks 里的静态对象持有。 +/// Benchmark fixture. Lifetime is owned by the static object in RegisterNetBenchmarks. struct Fixture { std::unique_ptr pair; - std::unique_ptr driver_perframe; ///< feth1,关掉批量写 - std::unique_ptr driver_batched; ///< feth1,开启批量写 - std::unique_ptr system_link; ///< feth0,用于灌入方向流量 + std::unique_ptr driver_perframe; ///< feth1, batch write disabled + std::unique_ptr driver_batched; ///< feth1, batch write enabled + std::unique_ptr system_link; ///< feth0, used to inject traffic bool batch_write_available = false; }; Fixture* g_fixture = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -/// 写入基准:把 `frames_per_write` 帧作为一批交给 WriteFrames。 +/// Write benchmark: passes `frames_per_write` frames as one batch to WriteFrames. /// -/// 计量单位是**帧**,这样逐帧写与批量写的数字可以直接横向比较。 +/// The measurement unit is frames so numbers for per-frame and batched writes can be +/// compared directly. std::uint64_t BenchWrite(net::BpfLink& link, std::uint64_t frame_iterations, std::uint32_t frame_bytes, std::uint32_t frames_per_write) { const std::vector frame = MakeFrame(frame_bytes); @@ -164,7 +168,7 @@ std::uint64_t BenchWrite(net::BpfLink& link, std::uint64_t frame_iterations, while (written < frame_iterations) { const auto result = link.WriteFrames(batch); if (!result) { - break; // 对侧队列打满等情况:诚实地按实际写成的帧数计。 + break; // peer queue saturated etc.: honestly count only the frames actually written. } written += result->frames_written; DoNotOptimize(result->bytes_written); @@ -172,10 +176,11 @@ std::uint64_t BenchWrite(net::BpfLink& link, std::uint64_t frame_iterations, return written; } -/// 读取基准:从 feth0 灌 `burst` 帧,再从 feth1 上把它们读回来。 +/// Read benchmark: injects `burst` frames from feth0, then reads them back on feth1. /// -/// ⚠️ 这一项测的是**写 + 读**的合计成本,不是纯读取 —— harness 只能给整个 -/// 闭包计时。要拿纯读取成本,请减去同批大小下的写入基准值。 +/// This measures the combined cost of write + read, not pure read -- the harness can only +/// time the entire closure. To obtain the pure read cost, subtract the write benchmark value +/// for the same batch size. std::uint64_t BenchWriteThenRead(std::uint64_t frame_iterations, std::uint32_t frame_bytes, std::uint32_t burst) { Fixture& fixture = *g_fixture; @@ -192,7 +197,8 @@ std::uint64_t BenchWriteThenRead(std::uint64_t frame_iterations, std::uint32_t f break; } std::uint32_t drained = 0; - // 最多转几圈就放弃:feth 会因队列压力丢帧,等不到全部回来是正常的。 + // Give up after a few spins: feth drops frames under queue pressure, so not getting all + // of them back is normal. for (int attempt = 0; attempt < 4 && drained < written->frames_written; ++attempt) { const auto received = fixture.driver_batched->ReadFrames(); if (!received) { @@ -206,9 +212,11 @@ std::uint64_t BenchWriteThenRead(std::uint64_t frame_iterations, std::uint32_t f return completed; } -/// feth 数据路径往返延迟:从驱动侧写 ARP 请求,等系统侧 IP 栈的 reply 读回来。 +/// feth data-path round-trip latency: writes an ARP request from the driver side and waits +/// for the reply from the system-side IP stack. /// -/// 这是唯一一项真正的**延迟**指标 —— 前面几项都是吞吐意义上的摊薄成本。 +/// This is the only true latency metric -- the previous items are amortized costs in the +/// throughput sense. std::uint64_t BenchArpRoundTrip(std::uint64_t iterations) { Fixture& fixture = *g_fixture; const std::vector request = MakeArpRequest(); @@ -234,7 +242,8 @@ std::uint64_t BenchArpRoundTrip(std::uint64_t iterations) { } } if (!answered) { - break; // 邻居表已经有缓存时系统侧不再应答,就此打住而不是虚报。 + break; // the system side stops replying once the neighbor table is cached; stop here + // instead of reporting a false result. } ++completed; } @@ -245,7 +254,7 @@ std::uint64_t BenchArpRoundTrip(std::uint64_t iterations) { bool RegisterNetBenchmarks(Runner& runner, std::string& skip_reason) { if (!net::IsRunningAsRoot()) { - skip_reason = "需要 root(要建 feth 网卡对并打开 /dev/bpf*)"; + skip_reason = "requires root (to create an feth pair and open /dev/bpf*)"; return false; } @@ -253,14 +262,14 @@ bool RegisterNetBenchmarks(Runner& runner, std::string& skip_reason) { auto pair = net::FethPair::Create(1500); if (!pair) { - skip_reason = std::format("创建 feth 网卡对失败:{}", pair.error().ToString()); + skip_reason = std::format("failed to create feth pair: {}", pair.error().ToString()); return false; } fixture.pair = std::make_unique(std::move(*pair)); const std::string assign_error = AssignIpv4(fixture.pair->SystemSide().Name(), kSystemIp); if (!assign_error.empty()) { - skip_reason = std::format("给系统侧配地址失败:{}", assign_error); + skip_reason = std::format("failed to assign address to system side: {}", assign_error); return false; } @@ -277,23 +286,24 @@ bool RegisterNetBenchmarks(Runner& runner, std::string& skip_reason) { fixture.system_link = open(fixture.pair->SystemSide().Name(), true); if (fixture.driver_perframe == nullptr || fixture.driver_batched == nullptr || fixture.system_link == nullptr) { - skip_reason = "打开 BPF 描述符失败"; + skip_reason = "failed to open BPF descriptor"; return false; } fixture.batch_write_available = fixture.driver_batched->SupportsBatchWrite(); g_fixture = &fixture; - // 每轮的帧数:写入是系统调用级别的开销(百纳秒~微秒量级),几万帧足够稳定, - // 再多只是让整轮跑得更久。 + // Frames per round: writes are system-call-level costs (hundreds of nanoseconds to + // microseconds), so tens of thousands of frames are stable enough; more would only make + // each round take longer. constexpr std::uint64_t kWriteOps = 20000; constexpr std::uint64_t kReadOps = 20000; - runner.Add("BPF 写入(逐帧 write)", "1514 字节", + runner.Add("BPF write (per-frame write)", "1514 bytes", Config{.ops_per_round = kWriteOps, .bytes_per_op = kFullFrameBytes}, [](std::uint64_t n) { return BenchWrite(*g_fixture->driver_perframe, n, kFullFrameBytes, 1); }); - runner.Add("BPF 写入(逐帧 write)", "64 字节", + runner.Add("BPF write (per-frame write)", "64 bytes", Config{.ops_per_round = kWriteOps, .bytes_per_op = kSmallFrameBytes}, [](std::uint64_t n) { return BenchWrite(*g_fixture->driver_perframe, n, kSmallFrameBytes, 1); @@ -301,27 +311,29 @@ bool RegisterNetBenchmarks(Runner& runner, std::string& skip_reason) { if (fixture.batch_write_available) { for (const std::uint32_t per_write : {8U, 32U, 64U, 128U}) { - runner.Add("BPF 写入(BIOCSBATCHWRITE)", std::format("1514 字节 × 每次 {} 帧", per_write), + runner.Add("BPF write (BIOCSBATCHWRITE)", + std::format("1514 bytes x {} frames per write", per_write), Config{.ops_per_round = kWriteOps, .bytes_per_op = kFullFrameBytes}, [per_write](std::uint64_t n) { return BenchWrite(*g_fixture->driver_batched, n, kFullFrameBytes, per_write); }); } - runner.Add("BPF 写入(BIOCSBATCHWRITE)", "64 字节 × 每次 64 帧", + runner.Add("BPF write (BIOCSBATCHWRITE)", "64 bytes x 64 frames per write", Config{.ops_per_round = kWriteOps, .bytes_per_op = kSmallFrameBytes}, [](std::uint64_t n) { return BenchWrite(*g_fixture->driver_batched, n, kSmallFrameBytes, 64); }); } - runner.Add("feth 往返(写+读合计)", "1514 字节 × 每批 64 帧", + runner.Add("feth round trip (write+read total)", "1514 bytes x 64 frames per batch", Config{.ops_per_round = kReadOps, .bytes_per_op = kFullFrameBytes}, [](std::uint64_t n) { return BenchWriteThenRead(n, kFullFrameBytes, 64); }); - // 往返延迟只跑几百次:每次都要等对侧 IP 栈真的应答,是毫秒不到但远慢于 - // 前面几项的操作,跑太多没有额外信息量。 - runner.Add("feth 往返延迟", "ARP 请求 → 系统侧 IP 栈应答", Config{.ops_per_round = 200}, - [](std::uint64_t n) { return BenchArpRoundTrip(n); }); + // Round-trip latency runs only a few hundred times: each iteration must wait for a real + // reply from the peer IP stack, which is under a millisecond but far slower than the + // previous operations, so running more adds no information. + runner.Add("feth round-trip latency", "ARP request -> system-side IP stack reply", + Config{.ops_per_round = 200}, [](std::uint64_t n) { return BenchArpRoundTrip(n); }); return true; } @@ -330,8 +342,8 @@ void ShutdownNetBenchmarks() noexcept { if (g_fixture == nullptr) { return; } - // 顺序要紧:先关 BPF 描述符,再销毁 feth 网卡对。反过来的话 - // BpfLink 析构时它挂着的接口已经没了。 + // Order matters: close BPF descriptors first, then destroy the feth interface pair. The + // other way around would leave BpfLink's destructor with an interface that no longer exists. g_fixture->system_link.reset(); g_fixture->driver_batched.reset(); g_fixture->driver_perframe.reset(); diff --git a/benchmarks/bench_net.h b/benchmarks/bench_net.h index d666185..1918dd1 100644 --- a/benchmarks/bench_net.h +++ b/benchmarks/bench_net.h @@ -1,8 +1,9 @@ -// feth / BPF 链路层基准的注册入口。 +// Registration entry point for feth / BPF link-layer benchmarks. // -// 与其余基准不同,这一组**需要 root**(要建 feth 网卡对、要开 /dev/bpf*)。 -// 非 root 时不注册任何项,并通过 skip_reason 说明原因 —— 报告里要能看见 -// 「这组没跑」,而不是让它静默消失。 +// Unlike the other benchmarks, this group requires root (to create feth interface pairs and +// open /dev/bpf*). When not running as root, no items are registered and the reason is +// reported via skip_reason -- the report must show "this group did not run" rather than +// letting it disappear silently. #pragma once #include @@ -11,19 +12,20 @@ namespace tetherkit::bench { class Runner; -/// 建立 feth + BPF 夹具并注册链路层基准。 +/// Sets up the feth + BPF fixture and registers the link-layer benchmarks. /// -/// @param skip_reason 返回 false 时写入跳过原因。 -/// @return 是否成功注册。夹具的生命周期由本模块的静态对象持有, -/// 一直存活到进程退出。 +/// @param skip_reason receives the skip reason when false is returned. +/// @return Whether registration succeeded. The fixture lifetime is owned by a static object +/// in this module and lives until process exit. bool RegisterNetBenchmarks(Runner& runner, std::string& skip_reason); -/// 拆掉夹具(关 BPF 描述符、销毁 feth 网卡对)。 +/// Tears down the fixture (closes BPF descriptors, destroys the feth interface pair). /// -/// **必须在 main 返回之前显式调用。** 放任夹具活到静态析构阶段会崩: -/// FethDevice 的析构函数会写日志,而那时日志模块的静态互斥量可能已经先一步 -/// 被销毁,于是抛出 `std::system_error: mutex lock failed`,进程以 134 退出 —— -/// 基准明明全部跑完了,退出码却是失败。 +/// Must be called explicitly before main returns. Letting the fixture live until static +/// destruction crashes: FethDevice's destructor writes logs, but by then the logging module's +/// static mutex may already have been destroyed, throwing `std::system_error: mutex lock +/// failed` and exiting the process with 134 -- the benchmarks all finished, yet the exit code +/// is a failure. void ShutdownNetBenchmarks() noexcept; } // namespace tetherkit::bench diff --git a/benchmarks/bench_rndis.cc b/benchmarks/bench_rndis.cc index 92de015..64578e4 100644 --- a/benchmarks/bench_rndis.cc +++ b/benchmarks/bench_rndis.cc @@ -1,8 +1,9 @@ -// RNDIS 编解码的微基准。 +// Micro-benchmarks for RNDIS encode/decode. // -// 这两条路径每帧都要走一次,是除系统调用之外唯一按帧计费的 CPU 成本: -// TX:PacketMessageWriter 把以太帧包成 RNDIS_PACKET_MSG(含多包聚合与对齐) -// RX:PacketMessageReader 从一个 bulk IN 传输里逐帧拆出来 +// Both paths run once per frame and are the only per-frame CPU costs besides system calls: +// TX: PacketMessageWriter wraps Ethernet frames into RNDIS_PACKET_MSG (including +// multi-packet aggregation and alignment) +// RX: PacketMessageReader extracts frames one by one from a bulk IN transfer #include "bench_rndis.h" #include @@ -29,12 +30,13 @@ std::vector MakeFrame(std::uint32_t length) { return frame; } -/// TX 编码:把 `frames_per_transfer` 帧聚合进一个传输。 +/// TX encode: aggregates `frames_per_transfer` frames into one transfer. /// -/// 度量单位是**帧**而不是传输,这样不同聚合度的数字可以直接横向比较。 +/// The measurement unit is frames rather than transfers so numbers at different aggregation +/// levels can be compared directly. std::uint64_t BenchEncode(std::uint64_t frame_iterations, std::uint32_t frame_bytes, std::uint32_t frames_per_transfer, std::uint32_t alignment_bytes) { - // 传输缓冲要装得下 frames_per_transfer 个满帧。 + // The transfer buffer must fit frames_per_transfer full frames. const std::size_t capacity = static_cast(frames_per_transfer) * (rndis::kPacketMsgHeaderBytes + frame_bytes + alignment_bytes) + @@ -59,20 +61,21 @@ std::uint64_t BenchEncode(std::uint64_t frame_iterations, std::uint32_t frame_by } const std::uint32_t produced = writer.Finish(); DoNotOptimize(produced); - // ★ 必须同时强制观测**目标缓冲的内容**。只 DoNotOptimize(Finish() 的返回值) - // 时,编译器发现这块缓冲此后再也没人读,就会把 TryAppend 里那次 memcpy 整个 - // 消除掉 —— 于是测出来 14.5 ns/帧,比裸 memcpy 1514 字节(25 ns)还快, - // 一眼就知道不对。加上 ClobberMemory 才是真实成本。 + // The contents of the destination buffer must also be forced into observation. If only + // DoNotOptimize(Finish()'s return value) is used, the compiler sees that this buffer is + // never read again and eliminates the memcpy inside TryAppend entirely -- measuring + // 14.5 ns/frame, faster than a bare memcpy of 1514 bytes (25 ns), which is obviously + // wrong. Adding ClobberMemory gives the real cost. DoNotOptimize(transfer.data()[0]); ClobberMemory(); } return encoded; } -/// RX 解码:从一个已聚合好的传输里逐帧拆出来。 +/// RX decode: extracts frames one by one from an already-aggregated transfer. std::uint64_t BenchDecode(std::uint64_t frame_iterations, std::uint32_t frame_bytes, std::uint32_t frames_per_transfer) { - // 先用编码器造一个真实的多包传输,再反复解它。 + // First build a real multi-packet transfer with the encoder, then decode it repeatedly. const std::size_t capacity = static_cast(frames_per_transfer) * (rndis::kPacketMsgHeaderBytes + frame_bytes + 8) + @@ -114,7 +117,8 @@ std::uint64_t BenchDecode(std::uint64_t frame_iterations, std::uint32_t frame_by return decoded; } -/// 编码 + 解码往返,模拟「主机发出去、设备原样发回来」的完整 CPU 成本。 +/// Encode + decode round trip, simulating the full CPU cost of "host sends it out, device +/// sends it back unchanged". std::uint64_t BenchRoundTrip(std::uint64_t frame_iterations, std::uint32_t frame_bytes, std::uint32_t frames_per_transfer) { const std::size_t capacity = @@ -146,7 +150,7 @@ std::uint64_t BenchRoundTrip(std::uint64_t frame_iterations, std::uint32_t frame if (packed == 0) { break; } - ClobberMemory(); // 见 BenchEncode 里的说明:防止 memcpy 被跨迭代消除 + ClobberMemory(); // see the note in BenchEncode: prevent memcpy elimination across iterations rndis::PacketMessageReader reader(std::span{transfer.data(), transfer_bytes}, rndis::kDefaultMtu + rndis::kEthernetHeaderBytes); @@ -164,42 +168,44 @@ std::uint64_t BenchRoundTrip(std::uint64_t frame_iterations, std::uint32_t frame void RegisterRndisBenchmarks(Runner& runner) { constexpr std::uint64_t kFrameOps = 1'000'000; - // 聚合度对每帧成本的影响 —— 这是 --max-transfer-kb 这个调优旋钮的依据。 + // Aggregation level affects per-frame cost -- this is the basis for the --max-transfer-kb + // tuning knob. for (const std::uint32_t per_transfer : {1U, 4U, 10U}) { - runner.Add("RNDIS 编码", - std::format("1514 字节 × 每传输 {} 帧", per_transfer), + runner.Add("RNDIS encode", std::format("1514 bytes x {} frames per transfer", per_transfer), Config{.ops_per_round = kFrameOps, .bytes_per_op = kFullFrameBytes}, [per_transfer](std::uint64_t n) { return BenchEncode(n, kFullFrameBytes, per_transfer, 1); }); } - runner.Add("RNDIS 编码", "64 字节 × 每传输 10 帧", + runner.Add("RNDIS encode", "64 bytes x 10 frames per transfer", Config{.ops_per_round = kFrameOps, .bytes_per_op = kSmallFrameBytes}, [](std::uint64_t n) { return BenchEncode(n, kSmallFrameBytes, 10, 1); }); - // 对齐要求(PacketAlignmentFactor)会引入额外的 memset 与 MessageLength 回填。 - runner.Add("RNDIS 编码", "1514 字节 × 每传输 10 帧 × 对齐 128", + // Alignment requirements (PacketAlignmentFactor) introduce extra memset and MessageLength + // backfilling. + runner.Add("RNDIS encode", "1514 bytes x 10 frames per transfer x 128-byte alignment", Config{.ops_per_round = kFrameOps, .bytes_per_op = kFullFrameBytes}, [](std::uint64_t n) { return BenchEncode(n, kFullFrameBytes, 10, 128); }); - // 解码刻意**不填 bytes_per_op**(于是吞吐列显示「—」):解码器是**零拷贝**的, - // 它只解析头部并返回指向传输缓冲内部的视图,一个字节都不搬。给它算「Gbps」 - // 会得到上万这种荒谬数字,且与编码的吞吐不可比 —— 编码那边是真的在 memcpy。 + // Decoding deliberately does not set bytes_per_op (so the throughput column shows "-"): + // the decoder is zero-copy, it only parses headers and returns views pointing into the + // transfer buffer without moving any bytes. Computing Gbps for it would yield absurd + // numbers in the tens of thousands and would not be comparable to encoding, where + // memcpy actually happens. for (const std::uint32_t per_transfer : {1U, 4U, 10U}) { - runner.Add("RNDIS 解码(零拷贝)", - std::format("1514 字节 × 每传输 {} 帧", per_transfer), - Config{.ops_per_round = kFrameOps}, - [per_transfer](std::uint64_t n) { + runner.Add("RNDIS decode (zero-copy)", + std::format("1514 bytes x {} frames per transfer", per_transfer), + Config{.ops_per_round = kFrameOps}, [per_transfer](std::uint64_t n) { return BenchDecode(n, kFullFrameBytes, per_transfer); }); } - runner.Add("RNDIS 解码(零拷贝)", "64 字节 × 每传输 10 帧", + runner.Add("RNDIS decode (zero-copy)", "64 bytes x 10 frames per transfer", Config{.ops_per_round = kFrameOps}, [](std::uint64_t n) { return BenchDecode(n, kSmallFrameBytes, 10); }); - runner.Add("RNDIS 编解码往返", "1514 字节 × 每传输 10 帧", + runner.Add("RNDIS encode/decode round trip", "1514 bytes x 10 frames per transfer", Config{.ops_per_round = kFrameOps, .bytes_per_op = kFullFrameBytes}, [](std::uint64_t n) { return BenchRoundTrip(n, kFullFrameBytes, 10); }); - runner.Add("RNDIS 编解码往返", "64 字节 × 每传输 10 帧", + runner.Add("RNDIS encode/decode round trip", "64 bytes x 10 frames per transfer", Config{.ops_per_round = kFrameOps, .bytes_per_op = kSmallFrameBytes}, [](std::uint64_t n) { return BenchRoundTrip(n, kSmallFrameBytes, 10); }); } diff --git a/benchmarks/bench_rndis.h b/benchmarks/bench_rndis.h index 88f3c84..7efe09c 100644 --- a/benchmarks/bench_rndis.h +++ b/benchmarks/bench_rndis.h @@ -1,11 +1,11 @@ -// RNDIS 编解码微基准的注册入口。 +// Registration entry point for RNDIS codec micro-benchmarks. #pragma once namespace tetherkit::bench { class Runner; -/// 把 RNDIS 编解码的微基准注册到 runner 上。 +/// Registers RNDIS codec micro-benchmarks with the runner. void RegisterRndisBenchmarks(Runner& runner); } // namespace tetherkit::bench diff --git a/benchmarks/harness.cc b/benchmarks/harness.cc index b462ca9..b67cc69 100644 --- a/benchmarks/harness.cc +++ b/benchmarks/harness.cc @@ -14,7 +14,7 @@ namespace tetherkit::bench { namespace { -/// 读取字符串型 sysctl;失败返回 "unknown"。 +/// Reads a string-typed sysctl; returns "unknown" on failure. std::string SysctlString(const char* name) { std::size_t size = 0; if (::sysctlbyname(name, nullptr, &size, nullptr, 0) != 0 || size == 0) { @@ -24,14 +24,14 @@ std::string SysctlString(const char* name) { if (::sysctlbyname(name, value.data(), &size, nullptr, 0) != 0) { return "unknown"; } - // sysctl 返回的长度含终止符,去掉它。 + // The length returned by sysctl includes the terminator; remove it. while (!value.empty() && value.back() == '\0') { value.pop_back(); } return value; } -/// 读取整数型 sysctl;失败返回 -1。 +/// Reads an integer-typed sysctl; returns -1 on failure. std::int64_t SysctlInt(const char* name) { std::int64_t value = 0; std::size_t size = sizeof(value); @@ -41,7 +41,7 @@ std::int64_t SysctlInt(const char* name) { return value; } -/// 按数量级选择合适的单位,避免表格里出现一长串零。 +/// Chooses an appropriate unit by order of magnitude to avoid long strings of zeros in the table. std::string FormatOpsPerSecond(double ops) { std::array buffer{}; if (ops >= 1e9) { @@ -58,19 +58,20 @@ std::string FormatOpsPerSecond(double ops) { std::string FormatThroughput(const Result& result) { if (result.bytes_per_op == 0) { - return "—"; + return "-"; } std::array buffer{}; std::snprintf(buffer.data(), buffer.size(), "%.2f Gbps", result.Gigabitsps()); return buffer.data(); } -/// 离散度超过 30% 时加感叹号,提示读者这次测量受了环境干扰。 +/// Adds an exclamation mark when spread exceeds 30% to warn the reader that this measurement +/// was disturbed by the environment. std::string FormatSpread(const Result& result) { std::array buffer{}; const double spread = result.Spread(); std::snprintf(buffer.data(), buffer.size(), "%.1f%%%s", spread * 100.0, - spread > 0.30 ? " ⚠" : ""); + spread > 0.30 ? " !" : ""); return buffer.data(); } @@ -82,26 +83,27 @@ void PrintEnvironment() { const std::int64_t efficiency = SysctlInt("hw.perflevel1.logicalcpu"); const std::int64_t cacheline = SysctlInt("hw.cachelinesize"); - std::printf("## 测量环境\n\n"); - std::printf("| 项目 | 值 |\n|---|---|\n"); + std::printf("## Environment\n\n"); + std::printf("| Item | Value |\n|---|---|\n"); std::printf("| CPU | %s |\n", SysctlString("machdep.cpu.brand_string").c_str()); - std::printf("| 逻辑核 | %lld(性能核 %lld / 效率核 %lld) |\n", logical, performance, efficiency); - std::printf("| 缓存行 | %lld 字节(kCacheLineSize = %zu) |\n", cacheline, kCacheLineSize); - std::printf("| 系统 | macOS %s (%s) |\n", SysctlString("kern.osproductversion").c_str(), + std::printf("| Logical cores | %lld (performance %lld / efficiency %lld) |\n", logical, + performance, efficiency); + std::printf("| Cache line | %lld bytes (kCacheLineSize = %zu) |\n", cacheline, kCacheLineSize); + std::printf("| OS | macOS %s (%s) |\n", SysctlString("kern.osproductversion").c_str(), SysctlString("kern.osversion").c_str()); const std::string build{GetBuildDescription()}; - std::printf("| 构建 | %s |\n", build.c_str()); + std::printf("| Build | %s |\n", build.c_str()); std::printf("\n"); } void PrintMarkdownReport(const std::vector& results, std::string_view title) { std::printf("## %.*s\n\n", static_cast(title.size()), title.data()); - std::printf("| 分组 | 基准 | 中位数 ns/op | 最好 ns/op | 速率 | 吞吐 | 离散度 |\n"); + std::printf("| Group | Benchmark | Median ns/op | Best ns/op | Rate | Throughput | Spread |\n"); std::printf("|---|---|---:|---:|---:|---:|---:|\n"); std::string current_group; for (const Result& result : results) { - // 同组内只在第一行显示组名,表格更易读。 + // Show the group name only on the first row within a group to make the table easier to read. const std::string group_cell = result.group == current_group ? "" : result.group; if (!group_cell.empty()) { current_group = result.group; @@ -112,8 +114,11 @@ void PrintMarkdownReport(const std::vector& results, std::string_view ti FormatSpread(result).c_str()); } std::printf("\n"); - std::printf("> 离散度 =(最差轮 − 最好轮)/ 中位数。超过 30%% 会标 ⚠,说明测量期间存在\n"); - std::printf("> 后台干扰或被调度到了效率核,此时的绝对数值仅供参考。\n\n"); + std::printf( + "> Spread = (worst round - best round) / median. Above 30%% is flagged, indicating\n"); + std::printf( + "> background interference or scheduling onto an efficiency core; treat absolute values as " + "indicative only.\n\n"); } } // namespace tetherkit::bench diff --git a/benchmarks/harness.h b/benchmarks/harness.h index 0e60369..0fa6fe3 100644 --- a/benchmarks/harness.h +++ b/benchmarks/harness.h @@ -1,18 +1,21 @@ -// 轻量性能基准 harness。 +// Lightweight performance benchmark harness. // -// 为什么不用 google/benchmark: -// 本项目只关心三类指标 —— 每次操作的纳秒数、每秒帧数(pps)、每秒比特数 -// (Gbps)。google/benchmark 的自动迭代次数推断、统计模型与 JSON 输出都是 -// 我们不需要的,而它会引入 FetchContent 的联网依赖并显著拖慢构建。 -// 这个 harness 只有 200 行,却能直接输出可以贴进 docs/BENCHMARKS.md 的 -// Markdown 表格,省掉一层解析。 +// Why not google/benchmark: +// This project only cares about three kinds of metrics -- nanoseconds per operation, frames +// per second (pps), and bits per second (Gbps). google/benchmark's automatic iteration-count +// inference, statistical model, and JSON output are all unnecessary, and it would introduce +// a networked FetchContent dependency and significantly slow the build. This harness is +// only ~200 lines, yet it can directly output Markdown tables that can be pasted into +// docs/BENCHMARKS.md, saving a parsing layer. // -// 度量方法(每项基准): -// 1. 预热若干轮,让分支预测器、缓存与 CPU 频率进入稳态; -// 2. 正式跑 N 轮,每轮内部循环固定次数; -// 3. 取各轮**中位数**而非均值 —— macOS 上后台进程与 big.LITTLE 调度会造成 -// 偶发的长尾,中位数对此更稳健;同时报告最好值与相对离散度,便于判断 -// 这次测量是否可信。 +// Measurement method (per benchmark): +// 1. Warm up for several rounds to let the branch predictor, caches, and CPU frequency +// reach steady state; +// 2. Run N measured rounds, each with a fixed number of inner-loop iterations; +// 3. Take the median across rounds rather than the mean -- background processes and +// big.LITTLE scheduling on macOS cause occasional long tails, and the median is more +// robust; also report the best value and relative spread to judge whether this +// measurement is trustworthy. #pragma once #include @@ -29,77 +32,82 @@ namespace tetherkit::bench { -/// 阻止编译器把被测代码优化掉。 +/// Prevents the compiler from optimizing away the code under measurement. /// -/// 内联汇编把值声明为「被读取且内存可能被修改」,编译器因此不能消除产生它的 -/// 计算。这是 google/benchmark 的 DoNotOptimize 的同等实现。 +/// The inline assembly declares the value as "read and memory may be modified", so the +/// compiler cannot eliminate the computation that produced it. This is equivalent to +/// google/benchmark's DoNotOptimize. template inline void DoNotOptimize(T const& value) { asm volatile("" : : "r,m"(value) : "memory"); } -/// 让编译器认为所有内存都可能被读写,用于分隔两段不该被合并的代码。 +/// Tells the compiler that all memory may be read or written, used to separate two sections +/// of code that must not be merged. inline void ClobberMemory() { asm volatile("" : : : "memory"); } -/// 单项基准的度量结果。 +/// Measurement result for a single benchmark. struct Result { std::string name; std::string group; - std::uint64_t operations = 0; ///< 每轮的操作数(帧数 / 入队次数 / ...)。 - std::uint64_t bytes_per_op = 0; ///< 每次操作搬运的字节数;0 表示不适用。 + std::uint64_t operations = 0; ///< Operations per round (frames / enqueues / ...). + std::uint64_t bytes_per_op = 0; ///< Bytes transferred per operation; 0 means not applicable. double median_nanos_per_op = 0.0; double best_nanos_per_op = 0.0; double worst_nanos_per_op = 0.0; - /// 相对离散度 =(最差 - 最好)/ 中位数。超过 0.3 说明测量环境噪声大。 + /// Relative spread = (worst - best) / median. Above 0.3 means the measurement environment + /// was noisy. [[nodiscard]] double Spread() const { return median_nanos_per_op > 0.0 ? (worst_nanos_per_op - best_nanos_per_op) / median_nanos_per_op : 0.0; } - /// 每秒操作数(中位数)。 + /// Operations per second (median). [[nodiscard]] double OpsPerSecond() const { return median_nanos_per_op > 0.0 ? 1e9 / median_nanos_per_op : 0.0; } - /// 吞吐(Gbps)。仅当 bytes_per_op > 0 时有意义。 + /// Throughput (Gbps). Meaningful only when bytes_per_op > 0. [[nodiscard]] double Gigabitsps() const { return bytes_per_op == 0 ? 0.0 : OpsPerSecond() * static_cast(bytes_per_op) * 8.0 / 1e9; } }; -/// 一次基准运行的配置。 +/// Configuration for a single benchmark run. struct Config { - std::uint32_t warmup_rounds = 3; ///< 预热轮数,结果不计入。 - std::uint32_t measure_rounds = 9; ///< 正式轮数,取中位数(奇数便于取中)。 - std::uint64_t ops_per_round = 0; ///< 每轮的操作数;由各基准自行决定。 - std::uint64_t bytes_per_op = 0; ///< 每次操作的字节数,用于换算 Gbps。 + std::uint32_t warmup_rounds = 3; ///< Warm-up rounds, results not counted. + std::uint32_t measure_rounds = 9; ///< Measured rounds, take the median (odd for easy midpoint). + std::uint64_t ops_per_round = 0; ///< Operations per round; decided by each benchmark. + std::uint64_t bytes_per_op = 0; ///< Bytes per operation, used to compute Gbps. }; -/// 基准注册表与执行器。 +/// Benchmark registry and executor. class Runner { public: - /// 被测函数签名:给定本轮要执行的操作数,返回实际执行的操作数。 + /// Signature of the function under measurement: given the number of operations to run in + /// this round, returns the number actually executed. /// - /// 返回值而非固定使用入参,是为了让「跑到队列满就停」这类基准也能诚实报告。 + /// Returning a value instead of always using the argument lets benchmarks like "stop when + /// the queue is full" report honestly. using Body = std::function; - /// 注册一项基准。`group` 用于在汇总表里分组。 + /// Registers a benchmark. `group` is used to group entries in the summary table. void Add(std::string group, std::string name, Config config, Body body) { entries_.push_back(Entry{std::move(group), std::move(name), config, std::move(body)}); } - /// 依次执行全部基准,边跑边打印进度。 + /// Runs all benchmarks in order, printing progress as it goes. [[nodiscard]] std::vector RunAll() { std::vector results; results.reserve(entries_.size()); for (const Entry& entry : entries_) { - std::fprintf(stderr, " 正在测量 %s / %s ...\n", entry.group.c_str(), entry.name.c_str()); + std::fprintf(stderr, " measuring %s / %s ...\n", entry.group.c_str(), entry.name.c_str()); results.push_back(RunOne(entry)); } return results; @@ -116,7 +124,7 @@ class Runner { static Result RunOne(const Entry& entry) { const Config& config = entry.config; - // 预热:不记录,只为让缓存与频率进入稳态。 + // Warm-up: not recorded, only to let caches and frequency reach steady state. for (std::uint32_t i = 0; i < config.warmup_rounds; ++i) { DoNotOptimize(entry.body(config.ops_per_round)); } @@ -155,10 +163,11 @@ class Runner { std::vector entries_; }; -/// 把结果集渲染成 Markdown 表格,可直接粘进 docs/BENCHMARKS.md。 +/// Renders the result set as a Markdown table that can be pasted directly into docs/BENCHMARKS.md. void PrintMarkdownReport(const std::vector& results, std::string_view title); -/// 打印本机环境信息(CPU、核数、缓存行、编译配置),基准结果必须带上下文才有意义。 +/// Prints local environment information (CPU, core counts, cache line, build configuration); +/// benchmark results are only meaningful with context. void PrintEnvironment(); } // namespace tetherkit::bench diff --git a/cmake/CompilerWarnings.cmake b/cmake/CompilerWarnings.cmake index 417df02..dca1afe 100644 --- a/cmake/CompilerWarnings.cmake +++ b/cmake/CompilerWarnings.cmake @@ -1,7 +1,7 @@ -# CompilerWarnings.cmake —— 集中管理警告选项 +# CompilerWarnings.cmake -- centralized warning options. # -# 提供 INTERFACE 目标 tetherkit_warnings,所有本项目自己的目标都 link 它; -# 第三方代码(third_party/)不 link,避免噪声。 +# Provides the INTERFACE target tetherkit_warnings; every project-owned target +# links it, while third_party/ code does not (keeps the noise down). add_library(tetherkit_warnings INTERFACE) @@ -9,7 +9,8 @@ set(_tk_warnings -Wall -Wextra -Wpedantic - # 隐式转换是协议解析代码最容易出错的地方,必须开。 + # Implicit conversions are the most error-prone spots in protocol parsing + # code; this one is mandatory. -Wconversion -Wsign-conversion -Wshadow @@ -22,10 +23,12 @@ set(_tk_warnings -Wdouble-promotion -Wformat=2 -Wimplicit-fallthrough - # 数据路径上的隐式堆分配是性能杀手,让编译器帮忙盯着 VLA。 + # Implicit heap allocation on the data path is a performance killer; let + # the compiler police VLAs. -Wvla - # 结构体填充会影响我们手写的线格式结构体,需要 static_assert 兜底, - # 但 -Wpadded 噪声过大,因此不开,改用 static_assert(sizeof(...)) 校验。 + # Struct padding affects our hand-packed wire-format structs and deserves a + # static_assert backstop, but -Wpadded is far too noisy -- instead the code + # pins sizes with static_assert(sizeof(...)). ) target_compile_options(tetherkit_warnings INTERFACE ${_tk_warnings}) diff --git a/cmake/FindLibUSB.cmake b/cmake/FindLibUSB.cmake index f76ba48..0a50230 100644 --- a/cmake/FindLibUSB.cmake +++ b/cmake/FindLibUSB.cmake @@ -1,14 +1,15 @@ -# FindLibUSB.cmake —— 定位 libusb-1.0 +# FindLibUSB.cmake -- locate libusb-1.0. # -# 优先走 pkg-config(Homebrew 安装的 libusb 自带 .pc 文件,能正确给出 -# `include/libusb-1.0` 这个非常规的头文件目录);失败时回退到手工搜索常见前缀。 +# Prefer pkg-config (Homebrew's libusb ships a .pc file that correctly reports +# the unusual `include/libusb-1.0` header directory); fall back to a manual +# search over common prefixes if that fails. # -# 输出: -# LibUSB_FOUND —— 是否找到 -# LibUSB_VERSION —— 版本号字符串 -# LibUSB_INCLUDE_DIR —— 含 libusb.h 的目录 -# LibUSB_LIBRARY —— 库文件路径 -# LibUSB::LibUSB —— 可直接 link 的 IMPORTED 目标 +# Outputs: +# LibUSB_FOUND -- whether the library was found +# LibUSB_VERSION -- version string +# LibUSB_INCLUDE_DIR -- directory containing libusb.h +# LibUSB_LIBRARY -- library file path +# LibUSB::LibUSB -- IMPORTED target that can be linked directly find_package(PkgConfig QUIET) @@ -31,7 +32,8 @@ find_library( PATHS /opt/homebrew/opt/libusb/lib /usr/local/opt/libusb/lib /opt/local/lib /usr/local/lib /usr/lib) -# 版本号:pkg-config 最可靠;否则从头文件的 LIBUSB_API_VERSION 推断一个下限。 +# Version: pkg-config is the most reliable source; otherwise infer a lower +# bound from LIBUSB_API_VERSION in the header. if(PC_LIBUSB_VERSION) set(LibUSB_VERSION "${PC_LIBUSB_VERSION}") elseif(LibUSB_INCLUDE_DIR AND EXISTS "${LibUSB_INCLUDE_DIR}/libusb.h") @@ -57,7 +59,8 @@ if(LibUSB_FOUND AND NOT TARGET LibUSB::LibUSB) LibUSB::LibUSB PROPERTIES IMPORTED_LOCATION "${LibUSB_LIBRARY}" INTERFACE_INCLUDE_DIRECTORIES "${LibUSB_INCLUDE_DIR}") - # libusb 的 darwin 后端需要 IOKit 与 CoreFoundation,以及 Security(IOUSBHost 权限校验)。 + # libusb's darwin backend needs IOKit and CoreFoundation, plus Security + # (IOUSBHost permission checks). find_library(IOKIT_FRAMEWORK IOKit REQUIRED) find_library(COREFOUNDATION_FRAMEWORK CoreFoundation REQUIRED) find_library(SECURITY_FRAMEWORK Security REQUIRED) diff --git a/cmake/Optimizations.cmake b/cmake/Optimizations.cmake index 20aa4bc..9754a93 100644 --- a/cmake/Optimizations.cmake +++ b/cmake/Optimizations.cmake @@ -1,26 +1,31 @@ -# Optimizations.cmake —— 数据路径优化选项 +# Optimizations.cmake -- data path optimization options. # -# 提供 INTERFACE 目标 tetherkit_optimizations。 +# Provides the INTERFACE target tetherkit_optimizations. # -# 设计取舍: -# * 默认不加 -march/-mcpu:Apple Silicon 的 baseline(armv8.4-a for M1)已经包含 -# 我们需要的一切(LSE 原子指令、NEON),额外的 -mcpu=native 收益很小却让产物 -# 绑定到具体芯片代次。需要时用 -DTETHERKIT_NATIVE_ARCH=ON 打开。 -# * 不用 -O3:本项目热路径以 memcpy 与系统调用为主,-O3 的激进循环展开与向量化 -# 对此几乎无收益,反而增大代码体积、恶化 I-cache 命中。-O2(RelWithDebInfo 默认) -# 是更优选择。 -# * -fno-plt / -fvisibility=hidden 减少间接跳转与符号表体积。 +# Design trade-offs: +# * No -march/-mcpu by default: the Apple Silicon baseline (armv8.4-a for M1) +# already covers everything we need (LSE atomic instructions, NEON); the +# extra benefit of -mcpu=native is marginal while tying the binary to a +# specific chip generation. Turn it on with -DTETHERKIT_NATIVE_ARCH=ON +# when needed. +# * No -O3: the hot paths here are dominated by memcpy and syscalls, where +# -O3's aggressive loop unrolling and vectorization add almost nothing while +# growing code size and hurting I-cache hit rates. -O2 (RelWithDebInfo's +# default) is the better choice. +# * -fno-plt / -fvisibility=hidden trim indirect jumps and symbol table size. add_library(tetherkit_optimizations INTERFACE) target_compile_options( tetherkit_optimizations INTERFACE - # 内部符号默认隐藏:本项目不导出稳定 ABI,可让优化器更自由地内联。 + # Hide internal symbols by default: this project exports no stable ABI, + # and hidden symbols leave the optimizer freer to inline. -fvisibility=hidden -fvisibility-inlines-hidden - # 严格别名对我们手写的线格式解析是危险的(大量 reinterpret_cast),关掉。 - # 换来的代价很小:热路径的实际瓶颈是系统调用而非别名分析。 + # Strict aliasing is dangerous for our handwritten wire-format parsing + # (heavy reinterpret_cast use), so turn it off. The cost is negligible: + # the hot paths' real bottleneck is syscalls, not alias analysis. -fno-strict-aliasing) if(TETHERKIT_NATIVE_ARCH) @@ -29,7 +34,7 @@ if(TETHERKIT_NATIVE_ARCH) if(TK_HAS_MCPU_NATIVE) target_compile_options(tetherkit_optimizations INTERFACE -mcpu=native) else() - message(WARNING "编译器不支持 -mcpu=native,已跳过本机微架构优化。") + message(WARNING "The compiler does not support -mcpu=native; microarchitecture optimization skipped.") endif() endif() @@ -40,6 +45,6 @@ if(TETHERKIT_ENABLE_LTO) set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE ON PARENT_SCOPE) set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELWITHDEBINFO ON PARENT_SCOPE) else() - message(WARNING "不支持 LTO,已跳过:${TK_IPO_MSG}") + message(WARNING "LTO not supported, skipped: ${TK_IPO_MSG}") endif() endif() diff --git a/cmake/Sanitizers.cmake b/cmake/Sanitizers.cmake index bb5e343..e98e43d 100644 --- a/cmake/Sanitizers.cmake +++ b/cmake/Sanitizers.cmake @@ -1,18 +1,20 @@ -# Sanitizers.cmake —— 消毒器开关 +# Sanitizers.cmake -- sanitizer switches. # -# 提供 INTERFACE 目标 tetherkit_sanitizers。 +# Provides the INTERFACE target tetherkit_sanitizers. # -# 注意: -# * ASan 与 TSan 互斥,不能同时开。 -# * TSan 是本项目最重要的消毒器 —— 无锁 SPSC 环形队列、libusb 事件线程与 -# BPF 读写线程之间的内存序正确性只能靠它验证。建议至少跑一遍: +# Notes: +# * ASan and TSan are mutually exclusive; they cannot be on together. +# * TSan is this project's most important sanitizer -- memory-ordering +# correctness between the lock-free SPSC ring, the libusb event thread, and +# the BPF reader/writer threads can only be validated by it. Run at least: # cmake -B build-tsan -DTETHERKIT_ENABLE_TSAN=ON && ctest --test-dir build-tsan -# * sanitizer 会显著降低吞吐,性能基准务必在未开消毒器的 Release 构建上跑。 +# * Sanitizers cut throughput substantially; benchmarks must run on a +# Release build without any sanitizer enabled. add_library(tetherkit_sanitizers INTERFACE) if(TETHERKIT_ENABLE_ASAN AND TETHERKIT_ENABLE_TSAN) - message(FATAL_ERROR "AddressSanitizer 与 ThreadSanitizer 不能同时启用。") + message(FATAL_ERROR "AddressSanitizer and ThreadSanitizer cannot be enabled together.") endif() set(_tk_san_list "") @@ -27,8 +29,9 @@ endif() if(TETHERKIT_ENABLE_UBSAN) list(APPEND _tk_san_list undefined) - # 手写线格式结构体上会做未对齐访问吗?不会 —— 我们全部走逐字节读取, - # 因此 alignment 检查保持开启,用来抓真正的 bug。 + # Do we ever make unaligned accesses on hand-packed wire-format structs? + # No -- every read is byte-wise, so the alignment check stays on to catch + # genuine bugs. endif() set(_tk_ubsan_recover_opts "") @@ -48,7 +51,7 @@ if(_tk_san_list) -fno-omit-frame-pointer -g ${_tk_ubsan_recover_opts}) target_link_options(tetherkit_sanitizers INTERFACE -fsanitize=${_tk_san_flags}) - message(STATUS "已启用消毒器: ${_tk_san_flags}") + message(STATUS "Sanitizers enabled: ${_tk_san_flags}") else() set(TETHERKIT_SANITIZER_SUMMARY "none") endif() diff --git a/cmake/tetherkit_exports.txt b/cmake/tetherkit_exports.txt index 672e0ff..20f9744 100644 --- a/cmake/tetherkit_exports.txt +++ b/cmake/tetherkit_exports.txt @@ -1,10 +1,13 @@ -# libtetherkit.dylib 的导出符号白名单(-exported_symbols_list)。 +# Exported-symbol allowlist for libtetherkit.dylib (-exported_symbols_list). # -# 只导出 C ABI。C++ 侧的符号(tetherkit::* 的重整名、libusb 的静态副本)一律 -# 隐藏,理由有二: -# 1. 宿主进程里可能已经加载了别的 libusb,导出重名符号会让两者互相污染; -# 2. C++ 符号带 ABI 指纹,一旦泄漏出去就等于承诺了它的稳定性,而我们只想 -# 承诺 tetherkit_c.h 这一层。 +# Only the C ABI is exported. C++-side symbols (mangled tetherkit::* names and +# the static libusb copy) are hidden, for two reasons: +# 1. The host process may already have another libusb loaded; exporting +# identically named symbols would let them pollute each other. +# 2. C++ symbols carry an ABI fingerprint: once they leak out, that is a +# promise of their stability, and we only want to promise the +# tetherkit_c.h layer. # -# 前导下划线是 Mach-O 的 C 符号命名约定(C 的 tk_version → _tk_version)。 +# The leading underscore is the Mach-O naming convention for C symbols +# (C's tk_version -> _tk_version). _tk_* diff --git a/gui/.clang-tidy b/gui/.clang-tidy index a2d5b74..b502b7f 100644 --- a/gui/.clang-tidy +++ b/gui/.clang-tidy @@ -1,6 +1,8 @@ -# gui/ 是 Swift 包,不属于 CMake 构建,clang-tidy 不该管这里。 +# gui/ is a Swift package, not part of the CMake build; clang-tidy has no +# business here. # -# 唯一的 C 文件是 CTetherKit/shim.c,它只是 SwiftPM 的形式要求(每个 C target -# 至少要有一个源文件),套用 C++ 项目的命名与现代化规则毫无意义。 +# The only C file is CTetherKit/shim.c, which exists purely to satisfy a +# SwiftPM formality (every C target needs at least one source file). Applying +# the C++ project's naming and modernization rules to it is meaningless. InheritParentConfig: false Checks: '-*' diff --git a/gui/.gitignore b/gui/.gitignore index 5f63711..3339a90 100644 --- a/gui/.gitignore +++ b/gui/.gitignore @@ -1,4 +1,4 @@ -# SwiftPM 的构建产物与解析出的依赖状态。 +# SwiftPM build products and the resolved dependency state. .build/ .swiftpm/ Package.resolved diff --git a/gui/Package.swift b/gui/Package.swift index 9f292c5..9c92e1b 100644 --- a/gui/Package.swift +++ b/gui/Package.swift @@ -1,40 +1,48 @@ // swift-tools-version: 5.9 // -// TetherKit 的图形界面。 +// The graphical interface of TetherKit. // -// ★ 为什么是 SwiftPM 而不是 CMake 或 Xcode 工程 ★ +// Why SwiftPM rather than CMake or an Xcode project: // -// * CMake 的 Swift 支持只在 Ninja / Xcode 生成器下可用,而本仓库用的是 -// Unix Makefiles,硬换生成器会打扰现有的 C++ 构建流程; -// * Xcode 工程文件是不可读、不可靠地手写的 XML,进版本库只会带来合并冲突; -// * SwiftPM 只需要一个 Package.swift,`swift build` 即可,和源码分发 -// (Homebrew formula)的路线也一致。 +// * CMake's Swift support only works with the Ninja / Xcode generators, +// while this repository uses Unix Makefiles, and switching generators +// would disturb the existing C++ build flow; +// * An Xcode project file is XML that cannot be read or written +// reliably by hand, and putting it into version control only brings +// merge conflicts; +// * SwiftPM only needs a Package.swift, `swift build` is enough, and it +// matches the source distribution route (the Homebrew formula). // -// .app 包由 Scripts/build-gui.sh 组装 —— SwiftPM 只产出可执行文件, -// Info.plist、图标、内嵌 dylib 都在脚本里拼。 +// The .app bundle is assembled by Scripts/build-gui.sh -- SwiftPM only +// produces the executable; the Info.plist, the icon and the embedded +// dylib are all put together in the script. // -// ★ 怎么找到 libtetherkit ★ +// How libtetherkit is found: // -// C++ 侧先构建,产物在 /build/lib。路径通过环境变量传进来: +// The C++ side is built first, and its product lands in +// /build/lib. The path comes in through an environment variable: // // export TETHERKIT_LIB_DIR=$PWD/build/lib // swift build --package-path gui // -// Scripts/build-gui.sh 会替你设好。没设时退回仓库默认的 build/lib, -// 这样在仓库里直接 `swift build` 也能用。 +// Scripts/build-gui.sh sets it for you. Without it the repository +// default build/lib is used, so `swift build` inside the repository +// works too. import Foundation import PackageDescription -/// libtetherkit 所在目录。 +/// The directory libtetherkit lives in. /// -/// 用绝对路径:SwiftPM 的工作目录随调用方式变化,相对路径会在 -/// `swift build --package-path gui` 与 `cd gui && swift build` 之间给出不同结果。 +/// An absolute path: SwiftPM's working directory depends on how it is +/// invoked, and a relative path would give different results between +/// `swift build --package-path gui` and `cd gui && swift build`. let libraryDirectory: String = { if let fromEnvironment = ProcessInfo.processInfo.environment["TETHERKIT_LIB_DIR"], !fromEnvironment.isEmpty { return fromEnvironment } - // Package.swift 位于 /gui,因此 ../build/lib 就是默认产物目录。 + // Package.swift sits in /gui, so ../build/lib is the default + // product directory. let packageDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() return packageDirectory .deletingLastPathComponent() @@ -42,26 +50,37 @@ let libraryDirectory: String = { .path }() -/// 链接 libtetherkit 所需的全部标志。 +/// All the flags needed to link libtetherkit. /// -/// 这里必须用 unsafeFlags —— SwiftPM 没有「加一个库搜索路径」的安全接口。 -/// 本包是根包、不会被别人依赖,unsafeFlags 的限制不适用。 +/// This has to use unsafeFlags -- SwiftPM has no safe interface for +/// "add a library search path". This package is a root package and will +/// not be depended on by others, so the unsafeFlags restriction does not +/// apply. let tetherkitLinkerSettings: [LinkerSetting] = [ .unsafeFlags([ "-L\(libraryDirectory)", - // rpath 必须用 -Xlinker 逐段传给链接器。 - // 写成 gcc 风格的 "-Wl,-rpath,..." 会被 swiftc 当成自己的参数, - // 报「unknown argument」—— 这不是链接错误,而是驱动层就拒了。 + // The rpath has to be passed to the linker in pieces via -Xlinker. + // Written in the gcc style "-Wl,-rpath,...", swiftc takes it as + // its own argument and reports "unknown argument" -- that is not + // a link error, the driver layer simply refuses it. // - // 三条 rpath 各有用途,**顺序有讲究**(dyld 按声明顺序逐条试): - // 1. ../Frameworks —— 装进 .app 之后 dylib 在那里; - // 2. 可执行文件同级 —— helper 是裸可执行文件,dylib 就在它旁边; - // 3. 构建产物目录 —— 开发时 `swift run` 直接能跑。 + // The three rpaths each have a purpose, and the **order matters** + // (dyld tries them one by one in declaration order): + // 1. ../Frameworks -- that is where the dylib is once the app + // is installed into a .app; + // 2. next to the executable -- the helper is a bare executable + // and the dylib sits right next to it; + // 3. the build product directory -- so `swift run` works + // directly during development. // - // 构建目录必须排在**最后**:它是一个绝对路径,在开发机上一定存在。 - // 排在前面的话,打好包的 .app 在本机加载的仍是构建目录里的那份, - // 内嵌的副本永远得不到验证 —— 等换台机器才暴露,而那时已经晚了。 - // (Scripts/build-gui.sh 还会把这条 rpath 从发布产物里彻底删掉。) + // The build directory has to come **last**: it is an absolute + // path and therefore always exists on the development machine. + // Put it first and a packaged .app still loads the copy in the + // build directory on this machine, so the embedded copy never + // gets verified -- that only shows up on another machine, and by + // then it is too late. + // (Scripts/build-gui.sh also removes this rpath from the release + // artifacts entirely.) "-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks", "-Xlinker", "-rpath", "-Xlinker", "@executable_path", "-Xlinker", "-rpath", "-Xlinker", libraryDirectory, @@ -73,8 +92,8 @@ let tetherkitLinkerSettings: [LinkerSetting] = [ /// LC_BUILD_VERSION `sdk` stamp. SwiftPM links with /// `-target arm64-apple-macos14.0`, which stamps sdk 14.0 and forces /// the legacy chrome (compact, flat traffic lights) on every Tahoe -/// machine. Xcode-linked apps stamp the SDK in use — Hand Mirror is -/// minos 14.4 / sdk 26.5 — with the deployment target untouched. +/// machine. Xcode-linked apps stamp the SDK in use -- Hand Mirror is +/// minos 14.4 / sdk 26.5 -- with the deployment target untouched. /// Do the same here; minos stays 14.0 and the sdk stamp gates only /// the Tahoe appearance (verified: sdk 14.0 -> legacy lights, /// sdk 26.5 -> full-size glossy ones, same minos). @@ -87,32 +106,36 @@ let tetherkitAppLinkerSettings: [LinkerSetting] = [ let package = Package( name: "TetherKitGUI", - // macOS 14:@Observable 与 ContentUnavailableView 需要它。命令行部分仍然 - // 支持 13.3,两者是各自独立的产物,不必对齐。 + // macOS 14: @Observable and ContentUnavailableView need it. The + // command line part still supports 13.3; the two are independent + // artifacts and do not have to be aligned. platforms: [.macOS(.v14)], products: [ .executable(name: "TetherKitApp", targets: ["TetherKitApp"]), .executable(name: "tetherkit-helper", targets: ["TetherKitHelper"]), ], targets: [ - // C ABI 的模块映射。头文件是指向 include/tetherkit/capi/tetherkit_c.h - // 的符号链接,因此永远和 C++ 侧同步,不需要任何生成步骤。 + // The module map for the C ABI. The header is a symbolic link + // to include/tetherkit/capi/tetherkit_c.h, so it stays in sync + // with the C++ side forever and needs no generation step. .target(name: "CTetherKit"), - // App 与 helper 共享的 XPC 协议与数据模型。 - // 两边靠同一份源码保持一致,而不是各自抄一遍。 + // The XPC protocol and the data models shared by the app and + // the helper. Both sides stay consistent through one shared + // source rather than each keeping its own copy. .target(name: "TetherKitIPC"), - // C ABI 的 Swift 封装:把 tk_* 翻译成 Swift 的类型与错误。 + // The Swift wrapper of the C ABI: it turns tk_* into Swift types + // and errors. .target(name: "TetherKitCore", dependencies: ["CTetherKit", "TetherKitIPC"], linkerSettings: tetherkitLinkerSettings), - // 以 root 运行的特权 helper。 + // The privileged helper, running as root. .executableTarget(name: "TetherKitHelper", dependencies: ["TetherKitCore", "TetherKitIPC"]), - // 用户看到的 SwiftUI App(普通用户身份运行)。 + // The SwiftUI app the user sees (running as an ordinary user). .executableTarget(name: "TetherKitApp", dependencies: ["TetherKitCore", "TetherKitIPC", "TetherKitUIModel"], linkerSettings: tetherkitAppLinkerSettings), diff --git a/gui/Resources/App-Info.plist b/gui/Resources/App-Info.plist index ca265bf..541d01c 100644 --- a/gui/Resources/App-Info.plist +++ b/gui/Resources/App-Info.plist @@ -1,12 +1,13 @@ @@ -22,7 +23,8 @@ TetherKit CFBundlePackageType APPL - + CFBundleIconFile AppIcon CFBundleShortVersionString @@ -30,12 +32,15 @@ CFBundleVersion __TETHERKIT_VERSION__ - + CFBundleDevelopmentRegion en CFBundleLocalizations @@ -44,19 +49,21 @@ zh-Hans - + LSMinimumSystemVersion 14.0 NSHighResolutionCapable - + LSUIElement NSSupportsAutomaticTermination - + NSSupportsSuddenTermination diff --git a/gui/Resources/com.tetherkit.helper.plist b/gui/Resources/com.tetherkit.helper.plist index dcc926e..2c0d746 100644 --- a/gui/Resources/com.tetherkit.helper.plist +++ b/gui/Resources/com.tetherkit.helper.plist @@ -1,20 +1,25 @@ @@ -26,21 +31,23 @@ /Library/PrivilegedHelperTools/com.tetherkit.helper - + MachServices com.tetherkit.helper - + StandardOutPath /var/log/tetherkit-helper.log StandardErrorPath /var/log/tetherkit-helper.log - + SoftResourceLimits NumberOfFiles diff --git a/gui/Scripts/build-gui.sh b/gui/Scripts/build-gui.sh index 49b1150..c5b2b61 100755 --- a/gui/Scripts/build-gui.sh +++ b/gui/Scripts/build-gui.sh @@ -1,27 +1,30 @@ #!/usr/bin/env bash # -# 构建 TetherKit 的图形界面:TetherKit.app 与 tetherkit-helper。 +# Build the TetherKit GUI: TetherKit.app and tetherkit-helper. # -# 用法: -# ./gui/Scripts/build-gui.sh # 用默认的 build/ 目录 +# Usage: +# ./gui/Scripts/build-gui.sh # uses the default build/ directory # TETHERKIT_BUILD_DIR=/path ./gui/Scripts/build-gui.sh -# ./gui/Scripts/build-gui.sh --debug # 调试构建,编译快、便于断点 +# ./gui/Scripts/build-gui.sh --debug # debug build -- faster compile, breakpoints # -# 产物只有一个: -# dist/TetherKit.app —— 双击运行的图形界面 +# There is exactly one artifact: +# dist/TetherKit.app -- the double-clickable application # -# 特权组件的安装载荷(helper 二进制 + dylib + plist + 安装脚本)内嵌在 -# .app 的 Contents/Library/HelperTools/ 里,App 内「一键安装」与终端里的 -# install-helper.sh 用的都是这一份。 +# The privileged-helper install payload (helper binary + dylibs + plist + +# install script) is embedded under the .app's Contents/Library/HelperTools/; +# the in-app "Install privileged components" button and the terminal's +# install-helper.sh both consume exactly this bundle. # -# ⚠️ 本脚本里所有「变量后面紧跟中文标点」的地方都必须写成 ${VAR}。 -# bash 按字节判断标识符字符,UTF-8 locale 下全角标点的首字节(0xEF)会被 -# isalnum() 判为真、吸进变量名,得到 "VAR?: unbound variable"。 -# 这个坑在 C locale 下不复现,只在中文环境炸。 +# bash identifier note: a variable immediately followed by full-width or any +# non-ASCII text must be written ${VAR}. bash decides identifier characters +# per byte; under a UTF-8 locale the leading byte (0xEF) of full-width +# punctuation passes isalnum() and merges into the name, yielding +# "VAR?: unbound variable". The scripts carry English-only text now, but the +# rule stays so non-ASCII output can never re-introduce the trap. set -euo pipefail # ------------------------------------------------------------------------------ -# 路径与参数 +# Paths and arguments # ------------------------------------------------------------------------------ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" @@ -30,46 +33,48 @@ BUILD_DIR="${TETHERKIT_BUILD_DIR:-${REPO_ROOT}/build}" DIST_DIR="${REPO_ROOT}/dist" SWIFT_CONFIGURATION="release" -# 追加给 swift build 的额外参数。Homebrew formula 需要传 --disable-sandbox: -# brew 的构建沙箱里嵌套不了 SwiftPM 自己的 sandbox-exec(homebrew-core 里 -# 所有 Swift formula 都这么干)。本地构建不传即可。 +# Extra flags appended to swift build. Homebrew formulae must pass +# --disable-sandbox: brew's build sandbox cannot nest SwiftPM's own +# sandbox-exec (homebrew-core does this for every Swift formula). Local +# builds pass nothing. SWIFT_BUILD_FLAGS=() for argument in "$@"; do case "${argument}" in --debug) SWIFT_CONFIGURATION="debug" ;; --swift-build-flags=*) IFS=' ' read -r -a SWIFT_BUILD_FLAGS <<< "${argument#*=}" ;; - -h|--help) sed -n '2,20p' "${BASH_SOURCE[0]}"; exit 0 ;; - *) echo "未知参数:${argument}" >&2; exit 2 ;; + -h|--help) sed -n '2,16p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) echo "unknown option: ${argument}" >&2; exit 2 ;; esac done log() { printf '\033[32m==>\033[0m %s\n' "$1"; } -die() { printf '\033[31m错误:\033[0m%s\n' "$1" >&2; exit 1; } +die() { printf '\033[31merror: \033[0m%s\n' "$1" >&2; exit 1; } # ------------------------------------------------------------------------------ -# 前置检查 +# Preconditions # ------------------------------------------------------------------------------ -command -v swift >/dev/null || die "找不到 swift,请先安装 Xcode 或命令行工具" +command -v swift >/dev/null || die "swift not found; install Xcode or the command line tools first" LIB_DIR="${BUILD_DIR}/lib" if [[ ! -f "${LIB_DIR}/libtetherkit.dylib" ]]; then - die "找不到 ${LIB_DIR}/libtetherkit.dylib - 请先构建 C++ 部分: + die "cannot find ${LIB_DIR}/libtetherkit.dylib + build the C++ part first: cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo cmake --build build -j" fi VERSION="$(sed -n 's/^ VERSION \([0-9.]*\)$/\1/p' "${REPO_ROOT}/CMakeLists.txt" | head -1)" -[[ -n "${VERSION}" ]] || die "无法从 CMakeLists.txt 解析版本号" +[[ -n "${VERSION}" ]] || die "could not parse the version from CMakeLists.txt" -log "TetherKit ${VERSION}(Swift ${SWIFT_CONFIGURATION} 构建)" +log "TetherKit ${VERSION} (Swift ${SWIFT_CONFIGURATION} build)" # ------------------------------------------------------------------------------ -# 编译 Swift +# Compile Swift # ------------------------------------------------------------------------------ -log "编译 Swift 目标" -# ⚠️ 空数组在 macOS 自带的 bash 3.2 里与 set -u 相冲("${arr[@]}" 报 -# unbound variable),必须用 ${arr[@]+...} 这种守护写法展开。 +log "Compiling the Swift targets" +# An empty array conflicts with set -u under macOS's bundled bash 3.2 +# ("${arr[@]}" reports unbound variable), so the expansion must use the +# ${arr[@]+...} guard form. TETHERKIT_LIB_DIR="${LIB_DIR}" swift build \ --package-path "${GUI_DIR}" \ --configuration "${SWIFT_CONFIGURATION}" \ @@ -82,10 +87,10 @@ SWIFT_BIN_DIR="$(TETHERKIT_LIB_DIR="${LIB_DIR}" swift build \ --show-bin-path)" # ------------------------------------------------------------------------------ -# 组装 .app +# Assemble the .app # ------------------------------------------------------------------------------ APP_DIR="${DIST_DIR}/TetherKit.app" -log "组装 ${APP_DIR}" +log "Assembling ${APP_DIR}" rm -rf "${APP_DIR}" mkdir -p "${APP_DIR}/Contents/MacOS" "${APP_DIR}/Contents/Frameworks" \ @@ -96,66 +101,75 @@ sed "s/__TETHERKIT_VERSION__/${VERSION}/g" \ "${GUI_DIR}/Resources/App-Info.plist" > "${APP_DIR}/Contents/Info.plist" cp "${GUI_DIR}/Resources/AppIcon.icns" "${APP_DIR}/Contents/Resources/" -# ★ 必须用 cp -a,不能用 install ★ -# libtetherkit.dylib 与 libtetherkit.0.dylib 都是指向 .0.1.1.dylib 的软链。 -# install 不保留软链,会各拷一份**独立的真实文件**;后续 install_name_tool -# 只改到其中一份,而按 @rpath 加载的恰好是没改到的那份 —— 修了个不被使用的 -# 副本,问题却还在,且毫无征兆。这个坑记在 docs/GUI-SPIKE.md 第 7 节。 +# cp -a here is mandatory, never install: +# libtetherkit.dylib and libtetherkit.0.dylib are both symlinks to +# .0.1.1.dylib. install does not preserve symlinks -- it copies each as an +# independent real file, and install_name_tool then gets applied to only +# one of them while @rpath happens to resolve to the untouched one: a copy +# that is never used gets fixed, the problem persists, and nothing warns. +# Recorded in docs/GUI-SPIKE.md section 7. cp -a "${LIB_DIR}"/libtetherkit*.dylib "${APP_DIR}/Contents/Frameworks/" # ------------------------------------------------------------------------------ -# 组装特权组件载荷(内嵌进 .app) +# Assemble the privileged-helper payload (embedded into the .app) # -# ★ 为什么放在 Contents/Library/HelperTools/ 而不是旁边的 dist/helper/ ★ -# App 内「一键安装」要从一个固定位置拿到载荷,而 .app 是唯一保证被整体 -# 分发的单位 —— 载荷跟着它走,装到哪台机器都找得到。 +# Why Contents/Library/HelperTools/ and not a sibling dist/helper/: +# The in-app one-click install needs to fetch its payload from one fixed +# location, and the .app is the only unit guaranteed to be distributed as a +# whole -- the payload rides with it, on any machine it lands on. # -# ★ 为什么平铺一个目录 ★ -# 二进制、dylib、plist、安装脚本全在一层,install-helper.sh 整目录拷走 -# 即可,不用关心 bundle 的内部结构。dylib 在 Frameworks/ 已有一份给 App -# 运行时用,这里再放一份给安装器拷 —— 约 0.8 MB 的重复,换来安装源目录 -# 自包含,值得。 +# Why one flat directory: +# Binary, dylibs, plist, and install scripts all sit at a single level, so +# install-helper.sh can copy the directory wholesale without knowing +# anything about bundle internals. A second dylib copy for the installer, +# next to the App-facing one in Frameworks/, costs about 0.8 MB and buys a +# self-contained install source; worth it. # ------------------------------------------------------------------------------ PAYLOAD_DIR="${APP_DIR}/Contents/Library/HelperTools" -log "组装 ${PAYLOAD_DIR}" +log "Assembling ${PAYLOAD_DIR}" mkdir -p "${PAYLOAD_DIR}" cp "${SWIFT_BIN_DIR}/tetherkit-helper" "${PAYLOAD_DIR}/com.tetherkit.helper" cp -a "${LIB_DIR}"/libtetherkit*.dylib "${PAYLOAD_DIR}/" cp "${GUI_DIR}/Resources/com.tetherkit.helper.plist" "${PAYLOAD_DIR}/" -# 安装 / 卸载脚本也进载荷:只有 .app(没有仓库)的机器也能装能卸。 +# The install/uninstall scripts also ride in the payload, so a machine that +# has only the .app (no repo) can install and uninstall. cp "${SCRIPT_DIR}/install-helper.sh" "${SCRIPT_DIR}/uninstall-helper.sh" "${PAYLOAD_DIR}/" chmod 755 "${PAYLOAD_DIR}/install-helper.sh" "${PAYLOAD_DIR}/uninstall-helper.sh" -# 旧版布局把载荷放在 dist/helper/,留着只会误导 —— 那份不再被更新。 +# The legacy layout kept the payload at dist/helper/; leftovers only mislead +# -- that copy is no longer updated. rm -rf "${DIST_DIR}/helper" # ------------------------------------------------------------------------------ -# 内嵌 libusb +# Embed libusb # -# ★ 为什么要嵌 ★ -# Homebrew 的 libusb 装在 /opt/homebrew 下,产物拷到别的机器上就找不到了。 -# 而且一旦用开发者证书签名,Hardened Runtime 会因为 Team ID 不一致直接拒绝 -# 加载 Homebrew 的 ad-hoc 签名库(docs/GUI-SPIKE.md 第 7 节)。 +# Why embed: +# Homebrew's libusb lives under /opt/homebrew; an artifact copied to +# another machine will not find it there. And once the build is signed +# with a developer certificate, Hardened Runtime rejects Homebrew's +# ad-hoc-signed library outright over the Team ID mismatch +# (docs/GUI-SPIKE.md section 7). # -# ★ 改哪个文件 ★ -# libusb 是 **libtetherkit.dylib** 的依赖,不是可执行文件的。对着可执行文件 -# 查 otool -L 会得到空结果,整段处理被静默跳过 —— 装完看起来一切正常, -# 实际仍依赖 Homebrew。这个坑同样记在第 7 节。 +# Which file gets patched: +# libusb is a dependency of **libtetherkit.dylib**, not of the executable. +# Running otool -L on the executable returns nothing and the whole section +# is silently skipped: the install looks perfect while the binary still +# depends on Homebrew. Recorded in that same section 7. # ------------------------------------------------------------------------------ embed_libusb() { local destination_dir="$1" - # 真实文件(不是软链)才是要改的对象。 + # The real file (not a symlink) is the patch target. local real_dylib real_dylib="$(ls "${destination_dir}"/libtetherkit.*.*.*.dylib 2>/dev/null | head -1)" - [[ -n "${real_dylib}" ]] || die "在 ${destination_dir} 里找不到 libtetherkit 的实体文件" + [[ -n "${real_dylib}" ]] || die "no real libtetherkit file found in ${destination_dir}" local libusb_path libusb_path="$(otool -L "${real_dylib}" | awk '/libusb-1\.0/ {print $1; exit}')" if [[ -z "${libusb_path}" || "${libusb_path}" == @* ]]; then - return 0 # 没有绝对路径依赖,不用处理 + return 0 # no absolute-path dependency, nothing to do fi - [[ -f "${libusb_path}" ]] || die "libtetherkit 依赖 ${libusb_path},但该文件不存在" + [[ -f "${libusb_path}" ]] || die "libtetherkit depends on ${libusb_path}, but the file does not exist" local libusb_name libusb_name="$(basename "${libusb_path}")" @@ -165,29 +179,32 @@ embed_libusb() { install_name_tool -change "${libusb_path}" "@loader_path/${libusb_name}" "${real_dylib}" install_name_tool -id "@loader_path/${libusb_name}" "${destination_dir}/${libusb_name}" - # ★ 改完字节必须重签 ★ - # arm64 要求可执行文件与 dylib 有有效签名,install_name_tool 改字节会让原 - # 签名失效,之后加载会被内核直接拒绝(Killed: 9),而且日志里看不出原因。 + # Re-signing after byte edits is mandatory. + # arm64 requires valid signatures on executables and dylibs; + # install_name_tool invalidates the original one, after which load is + # rejected outright by the kernel (Killed: 9) with no reason in any log. codesign --force --sign - --timestamp=none "${destination_dir}/${libusb_name}" >/dev/null codesign --force --sign - --timestamp=none "${real_dylib}" >/dev/null } -log "内嵌 libusb" +log "Embedding libusb" embed_libusb "${APP_DIR}/Contents/Frameworks" embed_libusb "${PAYLOAD_DIR}" # ------------------------------------------------------------------------------ -# 摘掉指向构建目录的 rpath +# Strip the rpath that points at the build directory # -# Package.swift 为了让开发时 `swift run` 能直接跑,加了一条指向 build/lib 的 -# **绝对路径** rpath。发布产物里必须删掉它,否则:在开发机上它一定存在,dyld -# 会用那一份,内嵌到 .app 里的副本永远得不到验证 —— 等换台机器才暴露问题。 +# So that `swift run` works during development, Package.swift adds an +# **absolute-path** rpath into build/lib. A shipped artifact must not carry +# it: on the dev machine that path always exists, dyld picks that copy, and +# the copy embedded in the .app never gets exercised -- the problem only +# surfaces on some other machine. # ------------------------------------------------------------------------------ -log "移除构建目录 rpath" +log "Removing the build-directory rpath" strip_build_rpath() { local binary="$1" - # otool 的输出形如 " path /some/dir (offset 12)",锚在行尾匹配不到, - # 必须带上后面的 " (offset"。 + # otool's output looks like " path /some/dir (offset 12)"; anchoring + # at end-of-line matches nothing, so the trailing " (offset" must come along. if otool -l "${binary}" | grep -q "path ${LIB_DIR} (offset"; then install_name_tool -delete_rpath "${LIB_DIR}" "${binary}" codesign --force --sign - --timestamp=none "${binary}" >/dev/null @@ -197,21 +214,23 @@ strip_build_rpath "${APP_DIR}/Contents/MacOS/TetherKit" strip_build_rpath "${PAYLOAD_DIR}/com.tetherkit.helper" # ------------------------------------------------------------------------------ -# 签名 +# Signing # -# ad-hoc 签名(-)就够:源码构建的产物不带 quarantine,Gatekeeper 不参与。 -# 需要对外分发预编译产物时才需要 Developer ID + 公证。 -# 顺序必须是「先签内部的库,再签外层的 .app」—— 反过来内层的改动会让外层签名失效。 +# Ad-hoc signing (-) is enough: source-built artifacts carry no quarantine +# attribute, so Gatekeeper never engages. Developer ID + notarization are +# only needed to distribute prebuilt binaries. +# The order must be "sign the inner libraries first, then the outer .app" -- +# the other way around, any inner change invalidates the outer signature. # ------------------------------------------------------------------------------ -log "ad-hoc 签名" +log "Ad-hoc signing" codesign --force --sign - --timestamp=none "${PAYLOAD_DIR}/com.tetherkit.helper" >/dev/null codesign --force --sign - --timestamp=none --deep "${APP_DIR}" >/dev/null # ------------------------------------------------------------------------------ -# 自检 +# Self-check # ------------------------------------------------------------------------------ -log "自检" -codesign --verify --deep --strict "${APP_DIR}" || die ".app 签名校验失败" +log "Self-check" +codesign --verify --deep --strict "${APP_DIR}" || die ".app signature verification failed" # LC_BUILD_VERSION's sdk stamp must be >= 26, otherwise the whole app # renders legacy traffic lights on Tahoe (AGENTS.md pitfall 19). Enforce @@ -220,22 +239,23 @@ sdk_stamp="$(otool -l "${APP_DIR}/Contents/MacOS/TetherKit" | awk '/LC_BUILD_VER if [[ "$(printf '%s' "${sdk_stamp}" | cut -d. -f1)" -lt 26 ]]; then die "sdk stamp is ${sdk_stamp:-missing}; Tahoe would render legacy chrome (need >= 26, see -platform_version in Package.swift)" fi -echo " ✓ LC_BUILD_VERSION sdk = ${sdk_stamp}" +echo " OK LC_BUILD_VERSION sdk = ${sdk_stamp}" if otool -L "${APP_DIR}/Contents/Frameworks"/libtetherkit.*.*.*.dylib | grep -q '/opt/homebrew'; then - echo " ⚠️ 产物仍依赖 /opt/homebrew,拷到别的机器上会跑不起来" + echo " WARNING the artifact still depends on /opt/homebrew; it will not run once copied to another machine" else - echo " ✓ 无 Homebrew 绝对路径依赖" + echo " OK no absolute-path Homebrew dependency" fi cat <\033[0m %s\n' "$1"; } -die() { printf '\033[31m错误:\033[0m%s\n' "$1" >&2; exit 1; } +die() { printf '\033[31merror: \033[0m%s\n' "$1" >&2; exit 1; } -[[ "${EUID}" -eq 0 ]] || die "需要 root 权限,请用 sudo 运行本脚本" -[[ -f "${SOURCE_DIR}/${LABEL}" ]] || die "找不到 ${SOURCE_DIR}/${LABEL} - 请先构建:./gui/Scripts/build-gui.sh" +[[ "${EUID}" -eq 0 ]] || die "root privileges required; run this script with sudo" +[[ -f "${SOURCE_DIR}/${LABEL}" ]] || die "cannot find ${SOURCE_DIR}/${LABEL} + build it first: ./gui/Scripts/build-gui.sh" # ------------------------------------------------------------------------------ -# 先卸载旧的 +# Uninstall the old copy first # -# 顺序很重要:必须先 bootout 再覆盖文件。反过来的话 launchd 还持有旧二进制的 -# 引用,覆盖之后下一次拉起的可能仍是旧的,且没有任何提示。 +# The order matters: bootout must happen before the files are overwritten. +# The other way around, launchd still holds a reference to the old binary, +# and after the overwrite the next spawn may silently be the old one. # ------------------------------------------------------------------------------ if launchctl print "system/${LABEL}" >/dev/null 2>&1; then - log "卸载已注册的旧版本" + log "Uninstalling the registered old version" launchctl bootout "system/${LABEL}" 2>/dev/null || true fi # ------------------------------------------------------------------------------ -# 安装文件 +# Install the files # ------------------------------------------------------------------------------ -log "安装到 ${INSTALL_PATH}" +log "Installing to ${INSTALL_PATH}" mkdir -p "${TOOLS_DIR}" -# 用 cp -a 而不是 install:libtetherkit 的两个软链必须保留成软链。 -# install 会把它们各拷成独立的真实文件,之后 @rpath 加载到的是哪一份就说不准了。 +# cp -a rather than install: libtetherkit's two symlinks must survive as +# symlinks. install copies each of them into an independent real file, and +# after that there is no telling which copy @rpath ends up loading. cp -a "${SOURCE_DIR}/${LABEL}" "${INSTALL_PATH}" cp -a "${SOURCE_DIR}"/libtetherkit*.dylib "${TOOLS_DIR}/" if compgen -G "${SOURCE_DIR}/libusb-*.dylib" >/dev/null; then cp -a "${SOURCE_DIR}"/libusb-*.dylib "${TOOLS_DIR}/" fi -# LaunchDaemon 对权限有硬性要求:属主必须是 root:wheel,且**不能**对组或其他 -# 用户可写。不满足时 launchd 会拒绝加载,报的是含糊的 "Service cannot load in -# requested session"。 +# The LaunchDaemon has hard permission requirements: owner must be root:wheel, +# and it must NOT be group- or other-writable. When that is violated, launchd +# refuses to load it with the vague "Service cannot load in requested +# session". chown -R root:wheel "${TOOLS_DIR}" chmod 755 "${TOOLS_DIR}" chmod 544 "${INSTALL_PATH}" find "${TOOLS_DIR}" -maxdepth 1 -name '*.dylib' -type f -exec chmod 444 {} + -log "安装 ${PLIST_PATH}" +log "Installing ${PLIST_PATH}" cp "${SOURCE_DIR}/${LABEL}.plist" "${PLIST_PATH}" chown root:wheel "${PLIST_PATH}" chmod 644 "${PLIST_PATH}" # ------------------------------------------------------------------------------ -# 注册 +# Register # ------------------------------------------------------------------------------ -log "注册 LaunchDaemon" +log "Registering the LaunchDaemon" launchctl bootstrap system "${PLIST_PATH}" -# 声明了 MachServices 的服务是**按需**拉起的,注册完不会立刻有进程 —— -# 这是正常的,不要以为失败了。校验方式是看服务是否已登记。 +# A service that declares MachServices is spawned on demand: right after +# registration there is no process yet -- that is normal, not a failure. +# Validating means checking that the service is registered. if launchctl print "system/${LABEL}" >/dev/null 2>&1; then - log "完成。TetherKit.app 会自动检测到它,不需要重启 App。" + log "Done. TetherKit.app picks it up automatically; restarting the App is unnecessary." else - die "注册后仍查不到服务,请检查 /var/log/tetherkit-helper.log" + die "Service still not found after registration; check /var/log/tetherkit-helper.log" fi cat <\033[0m %s\n' "$1"; } -[[ "${EUID}" -eq 0 ]] || { printf '需要 root 权限,请用 sudo 运行\n' >&2; exit 1; } +[[ "${EUID}" -eq 0 ]] || { printf 'root privileges required; run with sudo\n' >&2; exit 1; } -# bootout 发的是 SIGTERM,helper 接住后会停会话并销毁虚拟网卡。 -# 必须在删文件**之前**做 —— 二进制没了它就没法优雅退出了。 +# bootout sends SIGTERM; the helper catches it, stops the sessions, and +# destroys the virtual interfaces. It must happen BEFORE the files are +# deleted -- with the binary gone, it can no longer shut down gracefully. if launchctl print "system/${LABEL}" >/dev/null 2>&1; then - log "停止并注销服务" + log "Stopping and unregistering the service" launchctl bootout "system/${LABEL}" 2>/dev/null || true - # 给它一点时间跑完停机拆除。 + # Give it a moment to finish its shutdown teardown. sleep 1 fi -log "删除文件" +log "Deleting files" rm -f "${PLIST_PATH}" "${INSTALL_PATH}" rm -f "${TOOLS_DIR}"/libtetherkit*.dylib "${TOOLS_DIR}"/libusb-*.dylib -# 兜底:helper 若曾被强杀,登记文件里可能还留着没销毁的网卡。 +# Backstop: if the helper was ever force-killed, the state file may still +# list interfaces it never destroyed. if [[ -f "${STATE_FILE}" ]]; then while read -r interface; do [[ "${interface}" =~ ^feth[0-9]+$ ]] || continue - log "销毁残留的虚拟网卡 ${interface}" + log "Destroying leftover virtual interface ${interface}" ifconfig "${interface}" destroy 2>/dev/null || true done < "${STATE_FILE}" rm -f "${STATE_FILE}" fi -log "已卸载" +log "Uninstalled" diff --git a/gui/Sources/CTetherKit/shim.c b/gui/Sources/CTetherKit/shim.c index 8fdf746..3c57b34 100644 --- a/gui/Sources/CTetherKit/shim.c +++ b/gui/Sources/CTetherKit/shim.c @@ -1,14 +1,18 @@ -// SwiftPM 要求每个 C target 至少有一个源文件,否则拒绝构建。 +// SwiftPM requires every C target to have at least one source file, otherwise +// it refuses to build. // -// 本 target 的全部内容其实是 include/tetherkit_c.h(一个指向 C++ 侧头文件的 -// 符号链接)—— 真正的实现在 libtetherkit.dylib 里。这个空的翻译单元只是用来 -// 满足 SwiftPM 的形式要求。 +// The entire content of this target is actually include/tetherkit_c.h (a +// symlink to the C++-side header) -- the real implementation lives in +// libtetherkit.dylib. This empty translation unit exists only to satisfy +// SwiftPM's formal requirement. // -// 顺带做一件有用的事:把头文件包一遍,编译期就能发现「头文件里写了 C++ 才认的 -// 语法」这类问题,而不用等到 Swift 侧导入失败。 +// It also does something useful along the way: wrapping the header catches +// problems like "the header contains syntax only C++ accepts" at compile time, +// rather than waiting for the Swift-side import to fail. #include "tetherkit_c.h" -// 一个不导出的哑符号,防止某些链接器对完全空的目标文件发出告警。 +// A non-exported dummy symbol to prevent some linkers from warning about a +// completely empty object file. static const int tetherkit_c_shim_anchor = 0; const int* tetherkit_c_shim_unused(void); const int* tetherkit_c_shim_unused(void) { return &tetherkit_c_shim_anchor; } diff --git a/gui/Sources/TetherKitApp/AppModel.swift b/gui/Sources/TetherKitApp/AppModel.swift index 0448ed1..640b033 100644 --- a/gui/Sources/TetherKitApp/AppModel.swift +++ b/gui/Sources/TetherKitApp/AppModel.swift @@ -4,7 +4,7 @@ import SwiftUI import TetherKitCore import TetherKitIPC -/// 一次采样得到的瞬时速率。 +/// Instantaneous rate measured by one sample. struct ThroughputSample: Identifiable { let id = UUID() let timestamp: Date @@ -18,11 +18,14 @@ struct ThroughputSample: Identifiable { transmitPacketsPerSecond: 0) } -/// 连续重复的日志折叠成一条。 +/// Consecutive repeated log lines collapse into a single row. /// -/// 周期性路径(比如每 2 秒一次的设备枚举)会把同一句话刷成一整列,淹没真正 -/// 有信息量的行;折叠成「一行 ×N」承载同样的信息。id 取首条的 —— 重复只更新 -/// 计数与时间戳,行的身份不变,列表不用整行重建。 +/// A periodic path (the device enumeration every 2 seconds, for example) +/// repeats the same sentence down the whole column and buries the lines +/// that actually carry information; collapsing them into "one line x N" +/// carries the same information. The id comes from the first entry -- a +/// repeat only updates the count and the timestamp, the row's identity does +/// not change, so the list never needs a full rebuild. struct CollapsedLogEntry: Identifiable { let first: LogEntry private(set) var latest: LogEntry @@ -35,7 +38,9 @@ struct CollapsedLogEntry: Identifiable { var id: UUID { first.id } - /// 时间戳以外完全相同才算重复 —— 消息里带着变化的值(地址、计数)就该分行。 + /// Only entries identical apart from the timestamp count as repeats -- + /// a message that carries a changing value (an address, a count) + /// deserves a line of its own. func matches(_ entry: LogEntry) -> Bool { entry.level == first.level && entry.thread == first.thread && entry.message == first.message @@ -47,15 +52,18 @@ struct CollapsedLogEntry: Identifiable { } } -/// helper 的可达性。界面靠它决定是「显示安装引导」还是「正常工作」。 +/// Helper reachability. The UI uses it to decide between "show the install +/// guide" and "work normally". enum HelperAvailability: Equatable { case unknown case available(version: String) case missing(reason: String) - /// 装着的 helper 比当前 App 旧(或新),XPC 接口对不上。 + /// The installed helper is older (or newer) than the current app, so + /// the XPC interface does not match. /// - /// 单独一个状态而不是并进 missing:这两种情况的解决办法不一样, - /// 一个是「去装」,一个是「去重装」,提示文案必须能区分。 + /// A state of its own rather than folded into missing: the two cases + /// need different remedies -- one is "go install it", the other is "go + /// reinstall it" -- and the wording has to tell them apart. case outdated(installed: Int, expected: Int) var isAvailable: Bool { @@ -64,47 +72,60 @@ enum HelperAvailability: Equatable { } } -/// 装着的特权组件与 App 自带的那份不是同一个版本。 +/// The installed privileged helper is not the same version as the one the +/// app ships. /// -/// ★ 与 `HelperAvailability.outdated` 的分工 ★ -/// 那个说的是**协议对不上**:方法签名都不一致了,再调下去要么卡住要么崩, -/// 所以必须挡在安装引导页上。这个说的是**协议仍兼容、版本却不一样** —— -/// 组件照常能用,只是它里面还是升级前的那份 libtetherkit,新版修的问题在 -/// 真正干活的那一侧一个都没修(`brew upgrade` 只换 .app,动不了 -/// /Library/PrivilegedHelperTools)。 +/// Division of labor with `HelperAvailability.outdated`: +/// That one means **the protocol does not match**: the method signatures +/// already differ, so any further call either hangs or crashes, which is +/// why it has to be blocked on the install guide page. This one means +/// **the protocol is still compatible but the versions differ** -- the +/// helper works as usual, it just still carries the pre-upgrade +/// libtetherkit, so none of the problems the new version fixes are fixed +/// on the side that actually does the work (`brew upgrade` only swaps the +/// .app and cannot touch /Library/PrivilegedHelperTools). /// -/// 所以它不拦路,只在管理行里点亮一个「更新特权组件」。协议号不变的版本 -/// (0.1.4 → 0.1.5 这种)以前完全没有提示,用户唯一能察觉的迹象是 -/// 「更新说明里写着修好的毛病还在」。 +/// So it does not block anything; it only lights up an "update the +/// privileged helper" action in the management row. Versions that keep +/// the protocol number (0.1.4 -> 0.1.5 and the like) used to give no +/// hint at all, and the only trace a user could notice was "the problem +/// the release notes say is fixed is still there". struct HelperVersionMismatch: Equatable { - /// 装着的那份,只取版本号(如 `"0.1.3"`)。 + /// The installed one, version number only (for example `"0.1.3"`). let installed: String - /// App 自带的那份 —— 点了按钮之后会装上去的版本。 + /// The one the app ships -- the version that gets installed once the + /// button is pressed. let expected: String } -/// 手动「检查更新」的结果,驱动一个弹窗。 +/// The result of a manual "Check for Updates...", which drives a popup. enum UpdateCheckResult: Equatable { case upToDate(current: String) case updateAvailable(UpdateChecker.Release) case failed(String) - /// 开发构建(`swift run` 的裸可执行文件)没有版本号,没法比。 + /// A development build (the bare executable from `swift run`) has no + /// version number, so there is nothing to compare. case unavailable } -/// 界面的全部状态与动作。 +/// All state and actions of the UI. /// -/// ★ 为什么所有事都经过 helper,而不是 App 自己调库 ★ -/// App 以普通用户身份运行,建 feth 和开 BPF 都要 root,所以会话只能在 helper -/// 里跑。设备枚举与环境预检虽然不需要 root,但也统一走 helper —— 会话跑起来后 -/// 设备已被 helper 独占,App 再去读只会得到不一致的结果。 +/// Why everything goes through the helper instead of the app calling the +/// library itself: +/// The app runs as an ordinary user, and creating feth and opening BPF +/// both need root, so the session can only run inside the helper. Device +/// enumeration and the environment preflight do not need root either, but +/// they go through the helper as well -- once a session runs, the device +/// is already exclusively held by the helper, and the app reading it +/// would only produce inconsistent results. @Observable @MainActor final class AppModel { - // MARK: - 对界面公开的状态 + // MARK: - State exposed to the UI private(set) var helperAvailability: HelperAvailability = .unknown - /// 装着的组件与 App 版本对不上时的两个版本号;一致(或还没探到)为 nil。 + /// The two version numbers when the installed helper and the app + /// disagree; nil when they match (or have not been probed yet). private(set) var helperVersionMismatch: HelperVersionMismatch? private(set) var environment: EnvironmentReport? private(set) var devices: [DeviceDescriptor] = [] @@ -115,20 +136,24 @@ final class AppModel { private(set) var logs: [LogEntry] = [] private(set) var droppedLogCount: UInt64 = 0 - /// 用户在设备列表里选中的那台。为 nil 表示「用找到的第一台」。 + /// The device the user picked in the device list. nil means "use the + /// first one found". var selectedDeviceID: String? - /// 会话配置里用户可调的部分。 + /// The parts of the session configuration the user can adjust. var requestedMTU: UInt32 = 1500 var adoptDeviceMAC: Bool = true - /// 网络配置表单。 + /// The network configuration form. var networkConfiguration: NetworkConfiguration = .dhcp - /// 界面语言偏好。改它会**同时**做三件事:切 Swift 侧的文案表、把语言推给 - /// libtetherkit(否则日志卡里会混进另一种语言)、再推给 helper(它以 root - /// 跑在 launchd 下,看不到用户的语言偏好)。 + /// Interface language preference. Changing it does **three** things at + /// once: switch the Swift-side string table, push the language to + /// libtetherkit (otherwise another language leaks into the log card), + /// and push it to the helper (it runs as root under launchd and cannot + /// see the user's language preference). /// - /// `didSet` 里顺带把 `languageRevision` 加一 —— 文案是从全局表里查的, - /// SwiftUI 无从得知它变了,得靠这个值把视图树整个重建一次。 + /// `didSet` also bumps `languageRevision` -- the strings come from a + /// global table lookup, so SwiftUI has no way of knowing they changed; + /// this value is what forces the whole view tree to rebuild once. var languagePreference: LanguagePreference = .system { didSet { guard languagePreference != oldValue else { return } @@ -136,84 +161,107 @@ final class AppModel { } } - /// 语言换了多少次。视图根上挂 `.id(model.languageRevision)`,靠它触发重建。 + /// How many times the language changed. The view root carries + /// `.id(model.languageRevision)`, which is what triggers the rebuild. private(set) var languageRevision = 0 - /// 正在等待某个特权操作完成 —— 界面据此禁用按钮并显示进度。 + /// A privileged operation is in flight -- the UI uses this to disable + /// buttons and show progress. private(set) var isBusy: Bool = false - /// 需要弹给用户看的错误。 + /// An error that has to be shown to the user. var alertMessage: String? - /// 已知的新版本。驱动管理行里的「有新版」提示;nil = 没有或没查过。 + /// The known new version. Drives the "a new version is available" hint + /// in the management row; nil = none, or not checked yet. private(set) var availableUpdate: UpdateChecker.Release? - /// 手动「检查更新」的结果弹窗。视图关掉弹窗时置回 nil。 + /// The result popup of the manual "Check for Updates...". The view + /// resets it to nil when the popup closes. var updateCheckResult: UpdateCheckResult? var logLevelFilter: LogLevel = .info - // MARK: - 内部 + // MARK: - Internals private let client = HelperClient() private var pollingTask: Task? - /// 上一次的状态快照,用来做差算速率。 + /// The previous status snapshot, used to compute rates by differencing. private var previousStatus: SessionStatus? private var sessionStartedAt: Date? - /// 上次枚举设备的时刻。用单调时钟,避免系统时间被调整时算出负的间隔。 + /// When the devices were last enumerated. Uses the monotonic clock, so + /// an adjusted system clock cannot produce a negative interval. private var lastDeviceRefresh: ContinuousClock.Instant? /// Main window visible? Affects poll cadence only; the dock icon /// policy lives in MainWindowManager. private var isWindowVisible = true - /// 缓存的授权令牌。为 nil 表示下次特权操作需要弹框。 + /// The cached authorization token. nil means the next privileged + /// operation has to pop the dialog. /// - /// 不自己记过期时间:系统的 timeout 由授权数据库控制(可能被管理员改), - /// 我们自己算一份只会和系统不一致。以 helper 的复核结果为准更可靠。 + /// We do not track the expiry ourselves: the system's timeout is + /// governed by the authorization database (an administrator may change + /// it), and a copy we compute ourselves would only disagree with the + /// system. The helper's re-check is the more reliable authority. private var cachedAuthorization: AuthorizationToken? - /// 系统授权框里显示的说明。 + /// The explanation shown in the system authorization dialog. /// - /// 不写这一句的话框里只有「TetherKit 想要进行更改」,用户无从判断该不该批准。 - /// 用计算属性而不是 `static let`:`static let` 只在类型第一次被用到时求值 - /// 一次,用户之后切换语言就再也不会更新了。 + /// Without this sentence the box only says "TetherKit wants to make + /// changes" and the user has no way to judge whether to approve. A + /// computed property rather than `static let`: `static let` is + /// evaluated only once, when the type is first used, so a later language + /// switch would never update it. private static var authorizationPrompt: String { L(.authPromptSession) } - /// 安装特权组件时授权框里的说明。单独一句 —— 这次批准的是「往系统目录里 - /// 装东西」,和上面的日常操作不是一回事,文案必须说实话。 + /// The explanation in the authorization dialog when installing the + /// privileged helper. A separate sentence -- what is being approved + /// here is "putting something into a system directory", which is not the + /// same kind of action as the daily operation above, so the wording has + /// to tell the truth. private static var helperInstallPrompt: String { L(.authPromptInstall) } - /// 卸载特权组件时授权框里的说明。 + /// The explanation in the authorization dialog when uninstalling the + /// privileged helper. private static var helperUninstallPrompt: String { L(.authPromptUninstall) } - /// 轮询周期。 + /// The polling interval. /// - /// 500 ms 是「看起来实时」与「别把 XPC 往返变成负担」的平衡点:更快对人眼 - /// 已无区别,更慢会让速率曲线看起来一顿一顿的。 + /// 500 ms is the balance point between "looks live" and "do not turn the + /// XPC round trip into a burden": any faster is indistinguishable to the + /// eye, any slower makes the rate curve look jerky. private static let pollInterval: Duration = .milliseconds(500) - /// 纯后台待机(窗口关着、会话没跑)时的轮询间隔。 + /// The polling interval while purely idling in the background (window + /// closed, no session running). private static let backgroundPollInterval: Duration = .seconds(2) - /// 设备枚举的最小间隔。 + /// The minimum interval between device enumerations. /// - /// 比状态轮询慢得多,因为枚举要 libusb_open 去读字符串描述符。2 秒的插拔 - /// 响应延迟用户基本感觉不到,而开销降到了原来的四分之一。 + /// Much slower than the status polling, because enumeration has to + /// libusb_open the device to read its string descriptors. A 2 second + /// plug/unplug response delay is barely noticeable to the user, while + /// the cost drops to a quarter of what it was. private static let deviceRefreshInterval: Duration = .seconds(2) - /// 吞吐曲线保留的采样点数。120 点 × 500 ms = 最近 60 秒。 + /// How many samples the throughput curve keeps. 120 points x 500 ms = + /// the last 60 seconds. private static let historyCapacity = 120 - /// 日志面板保留的行数。 + /// How many lines the log panel keeps. /// - /// 2000 行足够回溯一整次启动序列加若干分钟运行;再多 SwiftUI 的列表会开始卡。 + /// 2000 lines are enough to look back over a whole startup sequence plus + /// several minutes of running; beyond that the SwiftUI list starts to + /// stutter. private static let logCapacity = 2000 - // MARK: - 生命周期 + // MARK: - Lifecycle - /// 应用一个语言偏好:本进程 → libtetherkit → helper,一处都不能漏。 + /// Apply a language preference: this process -> libtetherkit -> helper, + /// none of them may be missed. /// - /// UserDefaults 只在这里写,读在 `restoreLanguagePreference()` 里 —— - /// 两边都走同一个键名常量,改名不会漏改一半。 + /// UserDefaults is written only here and read in + /// `restoreLanguagePreference()` -- both sides use the same key name + /// constant, so renaming it cannot miss one of them. func applyLanguage(_ preference: LanguagePreference) { let resolved = L10n.apply(preference) TetherKitLibrary.setLanguage(resolved) @@ -222,12 +270,14 @@ final class AppModel { Task { await client.setLanguage(resolved) } } - /// 启动时恢复上次选的语言。没存过就是 `.system`。 + /// Restore the language picked last time at startup. If nothing was + /// stored, it is `.system`. private func restoreLanguagePreference() { let stored = UserDefaults.standard.string(forKey: Self.languageDefaultsKey) let preference = stored.flatMap(LanguagePreference.init(rawValue:)) ?? .system - // 直接写存储属性会触发 didSet 再存一次盘,绕开它:这里是「恢复」, - // 不是「用户改了」。 + // Writing the stored property directly would fire didSet and save + // to disk again; bypass it: this is a "restore", not a "the user + // changed it". if preference != languagePreference { languagePreference = preference } else { @@ -244,7 +294,9 @@ final class AppModel { while !Task.isCancelled { guard let self else { return } await self.refresh() - // 间隔是动态的:会话在跑或窗口开着就保持流畅,纯后台待机时放慢。 + // The interval is dynamic: it stays fluid while a session + // runs or the window is open, and slows down while purely + // idling in the background. try? await Task.sleep(for: self.pollDelay) } } @@ -257,31 +309,38 @@ final class AppModel { pollingTask = nil } - /// 主窗口出现(含从菜单栏重新打开)。 + /// The main window appeared (including when it is reopened from the + /// menu bar). /// - /// 立刻刷一次而不是等下一个周期:后台轮询可能正睡在 2 秒的长间隔里, - /// 用户打开窗口的第一眼不该看到陈旧数据。 + /// Refresh right away instead of waiting for the next cycle: the + /// background polling may be sleeping through the 2 second long + /// interval, and the first thing the user sees after opening the + /// window should not be stale data. func windowDidAppear() { isWindowVisible = true Task { await refresh() } } - /// 主窗口关闭。App 转入后台模式,轮询继续(菜单栏靠它喂数据)但会放慢。 + /// The main window closed. The app switches to background mode; polling + /// continues (the menu bar feeds on it) but slows down. func windowDidDisappear() { isWindowVisible = false } - /// 当前该用的轮询间隔。 + /// The polling interval to use right now. /// - /// 三种情况用快节奏:会话在跑(菜单栏要显示实时速率)、正在启动/停止 - /// (用户在等结果)、窗口开着(用户在看)。只有「纯后台待机」才放慢 —— - /// 那时轮询唯一的产出是 helper 探活与设备扫描,没人需要它们每半秒一次。 + /// Three situations keep the fast pace: a session is running (the menu + /// bar has to show live rates), starting or stopping is in progress (the + /// user is waiting for a result), the window is open (the user is + /// looking at it). Only "purely idling in the background" slows down -- + /// there the only products of polling are the helper liveness probe and + /// the device scan, and nobody needs them twice a second. private var pollDelay: Duration { let sessionActive = status.runState == .running || status.runState.isTransitional return sessionActive || isWindowVisible ? Self.pollInterval : Self.backgroundPollInterval } - // MARK: - 轮询 + // MARK: - Polling private func refresh() async { do { @@ -289,10 +348,14 @@ final class AppModel { guard revision == HelperConstants.protocolRevision else { helperAvailability = .outdated(installed: revision, expected: HelperConstants.protocolRevision) - // 协议对不上时不再另报版本不一致:那张卡本来就是要用户去更新组件, - // 同一件事说两遍只会让人怀疑是两个问题。 + // When the protocol does not match, do not also report a + // version mismatch: that card exists to send the user off + // to update the helper, and saying the same thing twice + // only makes them suspect two separate problems. helperVersionMismatch = nil - // 接口对不上就别继续发请求了 —— 参数与应答的形状都可能不一致。 + // Do not keep sending requests when the interface does not + // match -- the shapes of the arguments and the replies may + // all differ. return } helperAvailability = .available(version: version) @@ -300,8 +363,9 @@ final class AppModel { } catch { helperAvailability = .missing(reason: error.localizedDescription) helperVersionMismatch = nil - // 连不上就别再发后续请求了 —— 每一个都会重复同样的失败, - // 只会把日志刷满。 + // Do not send further requests when the connection fails -- + // each one would repeat the same failure and only flood the + // log. return } @@ -316,17 +380,23 @@ final class AppModel { apply(feed: feed) } - // 设备列表只在没跑起来的时候刷:运行中设备已被独占,列表也不该变。 + // The device list is refreshed only while nothing is running: once + // running, the device is exclusively held and the list should not + // change. // - // 而且**刷得比状态慢**。枚举一次要读 USB 字符串描述符,那需要 - // libusb_open 真的把设备打开一遍 —— 按 500 ms 的状态轮询节奏做这件事, - // 等于每秒钟去开关用户的设备两次,既浪费又可能干扰它。插拔响应慢 2 秒 - // 完全不影响体感。 + // And it refreshes **more slowly than the status**. One enumeration + // has to read the USB string descriptors, which means libusb_open + // really has to open the device -- doing that on a 500 ms status + // polling cadence means switching the user's device on and off + // twice a second, wasteful and possibly disruptive to it. A 2 + // second plug/unplug response delay does not affect the feel of + // the product at all. if status.runState != .running, shouldRefreshDevices() { if let fresh = try? await client.listDevices() { devices = fresh - // 用户选中的设备被拔掉后,选择要跟着失效,否则「启动」会按一个 - // 不存在的总线地址去找设备。 + // When the selected device is unplugged, the selection + // has to become invalid as well, otherwise "Start" looks + // for a device at a bus address that does not exist. if let selected = selectedDeviceID, !devices.contains(where: { $0.id == selected }) { selectedDeviceID = nil @@ -349,7 +419,8 @@ final class AppModel { status = fresh } - // 记录/清除连接时刻,用于显示已连接时长。 + // Record/clear the moment of connection, used to show how long the + // session has been connected. if fresh.runState == .running, sessionStartedAt == nil { sessionStartedAt = Date() } else if fresh.runState != .running, fresh.runState != .stopping { @@ -365,8 +436,10 @@ final class AppModel { return } - // 分母用库那边的单调时钟差,而不是我们的轮询周期 —— 两次拉取之间的 - // 真实间隔会被调度拉长,用固定周期当分母会把速率算高。 + // The denominator is the monotonic clock difference on the library + // side, not our polling period -- scheduling stretches the real + // interval between two fetches, and using a fixed period as the + // denominator would overestimate the rate. let seconds = Double(fresh.monotonicNanos - previous.monotonicNanos) / 1_000_000_000 guard seconds > 0 else { return } @@ -394,9 +467,9 @@ final class AppModel { droppedLogCount += feed.droppedLogs } - // MARK: - 动作 + // MARK: - Actions - /// 已连接时长;未连接时为 nil。 + /// How long the session has been connected; nil when not connected. var connectedDuration: TimeInterval? { sessionStartedAt.map { Date().timeIntervalSince($0) } } @@ -408,11 +481,14 @@ final class AppModel { return devices.first } - /// 过滤 + 连续去重后的日志。 + /// The filtered and consecutively deduplicated logs. /// - /// 折叠放在过滤**之后**:原始流里两条相同的 INFO 之间可能夹着 trace, - /// 按原始顺序折叠会失效,而用户在某个级别下看到的相邻重复才是该合并的。 - /// O(n) 重算,n ≤ 2000,对 500 ms 的刷新节奏无感。 + /// Collapsing happens **after** filtering: in the raw stream two + /// identical INFO lines may have trace lines between them, so + /// collapsing in raw order would fail, while the adjacent repeats the + /// user sees at a given level are exactly the ones that should be + /// merged. Recomputed in O(n), n <= 2000, which is imperceptible at a + /// 500 ms refresh cadence. var filteredLogs: [CollapsedLogEntry] { var collapsed: [CollapsedLogEntry] = [] for entry in logs where entry.level >= logLevelFilter { @@ -425,7 +501,7 @@ final class AppModel { return collapsed } - /// 启动会话。会弹一次系统授权框。 + /// Start the session. Pops one system authorization dialog. func startSession() async { guard !isBusy else { return } isBusy = true @@ -433,8 +509,9 @@ final class AppModel { var configuration = SessionConfiguration(mtu: requestedMTU, adoptDeviceMAC: adoptDeviceMAC) - // 指定总线 + 地址而不是 VID/PID:同型号两台设备 VID/PID 完全一样, - // 只有总线地址能区分。 + // Specify the bus and address rather than VID/PID: two devices of + // the same model have exactly the same VID/PID, and only the bus + // address tells them apart. if let device = selectedDevice { configuration.busNumber = device.busNumber configuration.deviceAddress = device.deviceAddress @@ -447,7 +524,7 @@ final class AppModel { } } - /// 停止会话。 + /// Stop the session. func stopSession() async { guard !isBusy else { return } isBusy = true @@ -458,7 +535,7 @@ final class AppModel { } } - /// 下发网络配置。 + /// Push the network configuration. func applyNetworkConfiguration() async { guard !isBusy else { return } let interface = status.systemInterface @@ -477,15 +554,19 @@ final class AppModel { await authorized { [self] authorization in try await client.applyNetwork(authorization: authorization, interface: interface, configuration: networkConfiguration) - // 立刻回读一次,让界面马上反映真实生效的地址,而不用等下一个轮询周期。 + // Read it back right away so the UI reflects the address that + // actually took effect, without waiting for the next polling + // cycle. networkState = (try? await client.queryNetwork(interface: interface)) ?? .empty } } - /// 撤销网卡上的 IP 配置。 + /// Revoke the IP configuration on the interface. /// - /// 单独一个动作而不是「上网方式选『不配置』再点应用」:撤销是一次性操作, - /// 混进模式选择器里会让人以为选中它就已经生效了。 + /// A separate action rather than "pick 'Not configured' in the access + /// mode selector and press Apply": revoking is a one-shot operation, and + /// burying it in the mode selector makes people think that selecting it + /// has already taken effect. func clearNetworkConfiguration() async { guard !isBusy else { return } let interface = status.systemInterface @@ -501,15 +582,17 @@ final class AppModel { } } - /// 手动刷新设备列表(界面上的刷新按钮)。 + /// Refresh the device list manually (the refresh button in the UI). /// - /// 手动触发时不受节流限制 —— 用户点了按钮就是想立刻看到结果。 + /// Manual triggers are not throttled -- when the user presses the + /// button they want to see the result immediately. func refreshDevices() async { lastDeviceRefresh = .now devices = (try? await client.listDevices()) ?? [] } - /// 距上次枚举是否已经够久。顺带记下这一次的时刻。 + /// Whether enough time has passed since the last enumeration. Records + /// this round's time on the way. private func shouldRefreshDevices() -> Bool { let now = ContinuousClock.now if let last = lastDeviceRefresh, now - last < Self.deviceRefreshInterval { @@ -524,58 +607,77 @@ final class AppModel { droppedLogCount = 0 } - /// App 自带的库版本 —— 「特权组件应该是哪一版」的标准答案。 + /// The library version the app ships -- the standard answer to "which + /// version should the privileged helper be". /// - /// 用**库**的版本而不是 Info.plist 里的 App 版本号:装到 - /// /Library/PrivilegedHelperTools 的正是 .app 里那份 libtetherkit 的拷贝 - /// (见 build-gui.sh 组装载荷那段),两边同源才比得准。而且 `swift run` - /// 的裸可执行文件根本没有 bundle,读 Info.plist 那条路上这个判断会整个失效。 + /// The **library** version rather than the app version in Info.plist: + /// what gets installed into /Library/PrivilegedHelperTools is a copy of + /// the libtetherkit inside the .app (see the payload assembly in + /// build-gui.sh), and only same-origin values compare meaningfully. + /// Moreover the bare executable from `swift run` has no bundle at all, + /// so on that path the Info.plist check would fail outright. /// - /// 敢用 `static let` 缓存是因为它是编译期烧进 dylib 的常量,进程活着的时候 - /// 不会变 —— 与上面几个必须跟着语言走的提示语不同。顺手把版本号提取也 - /// 做掉:这个判断挂在 500 ms 一次的轮询上,没必要每次都重跑一遍正则。 + /// Caching it in a `static let` is safe because it is a constant + /// compiled into the dylib that cannot change while the process lives + /// -- unlike the prompts above, which have to follow the language. The + /// version extraction comes along for the ride: this check hangs off a + /// 500 ms polling loop, and there is no reason to re-run the regex + /// every time. private static let bundledVersion = HelperConstants.semanticVersion(of: TetherKitLibrary.versionInfo.version) - /// 装着的组件和 App 自带的是不是同一版。一致时返回 nil。 + /// Whether the installed helper is the same version as the one the app + /// ships. Returns nil when they match. private static func versionMismatch(installed: String) -> HelperVersionMismatch? { let installedVersion = HelperConstants.semanticVersion(of: installed) guard installedVersion != bundledVersion else { return nil } return HelperVersionMismatch(installed: installedVersion, expected: bundledVersion) } - /// 安装 / 更新特权组件(引导卡上的「一键安装」按钮,以及版本对不上时 - /// 管理行里的「更新特权组件」—— 更新就是照着 .app 里的载荷重装一遍)。 + /// Install / update the privileged helper (the "one-click install" + /// button on the guide card, and the "update the privileged helper" + /// action in the management row when the versions differ -- an update + /// is just reinstalling the payload from the .app). func installHelper() async { await maintainHelper(.install, prompt: Self.helperInstallPrompt, expectAvailable: true, failureNotice: L(.installScriptRanButUnreachable)) } - /// Uninstall the privileged helper (the "Uninstall…" action on the + /// Uninstall the privileged helper (the "Uninstall..." action on the /// Manage page; the confirmation alert lives in the UI layer). /// - /// bootout 给 helper 发 SIGTERM —— 正在跑的会话被优雅停掉、虚拟网卡销毁, - /// 这一点必须在确认框里向用户说明。卸载完成后界面自然回到安装引导页。 + /// bootout sends the helper a SIGTERM -- a running session is shut down + /// gracefully and the virtual interface destroyed, and that has to be + /// spelled out in the confirmation dialog. Once the uninstall finishes + /// the UI naturally returns to the install guide page. func uninstallHelper() async { await maintainHelper(.uninstall, prompt: Self.helperUninstallPrompt, expectAvailable: false, failureNotice: L(.uninstallScriptRanButStillAlive)) } - /// 安装与卸载共用的执行壳:授权(复用缓存令牌)→ AEWP 执行 → XPC 探测 - /// 兜底确认。 + /// The execution shell shared by install and uninstall: authorize + /// (reuse the cached token) -> AEWP execution -> XPC probe as a + /// backstop. /// - /// 授权与日常特权操作共用同一条权利(system.privilege.admin),令牌缓存 - /// 因此天然通用:装完 5 分钟内接着点「连接」不会再弹框。缓存令牌已过期的 - /// 罕见路径由 AEWP 自己弹系统默认文案的框兜底,不为它专造一次「带自定义 - /// 文案的重新授权」。 + /// The authorization and the daily privileged operations share the same + /// right (system.privilege.admin), so the token cache is naturally + /// universal: install the helper and press "Connect" within 5 minutes + /// and no dialog appears again. The rare path where the cached token + /// has expired falls back to the system-default wording dialog that + /// AEWP raises itself; no dedicated "re-authorize with custom wording" + /// is built for it. /// - /// ★ 成败判定 ★ - /// AEWP 不给退出码(见 HelperInstaller 的说明),所以脚本跑完后用真实的 - /// XPC 往返确认,兜几秒等 launchd 完成登记与按需拉起(或注销生效)。 - /// 方向由 expectAvailable 决定:安装等「连得上」,卸载等「连不上」。 - /// 失败时把脚本输出的末尾放进弹窗 —— 真实原因几乎总写在结尾。 + /// How success and failure are decided: + /// AEWP gives no exit code (see the HelperInstaller notes), so after + /// the script runs we confirm with a real XPC round trip, waiting a + /// few seconds for launchd to finish the registration and the + /// on-demand launch (or the logout to take effect). The direction is + /// decided by expectAvailable: install waits for "can connect", + /// uninstall waits for "cannot connect". On failure the tail of the + /// script output goes into the dialog -- the real reason is almost + /// always written at the end. private func maintainHelper(_ action: HelperInstaller.Action, prompt: String, expectAvailable: Bool, @@ -584,7 +686,9 @@ final class AppModel { isBusy = true defer { isBusy = false } - // 载荷与 AEWP 先行自检 —— 缺哪样都必须在弹授权框**之前**说清楚。 + // Preflight the payload and AEWP first -- whichever is missing + // has to be said clearly **before** the authorization dialog pops + // up. if let failure = HelperInstaller.preflightError() { alertMessage = failure.localizedDescription return @@ -618,9 +722,10 @@ final class AppModel { alertMessage = L(.scriptOutput, failureNotice, Self.tail(of: output)) } - // MARK: - 检查更新 + // MARK: - Update check - /// 手动检查(App 菜单「检查更新…」)。结果无论好坏都弹窗。 + /// Manual check (the "Check for Updates..." item in the app menu). The + /// result pops up a dialog whether it is good news or bad. func checkForUpdates() async { guard let current = UpdateChecker.currentVersion else { updateCheckResult = .unavailable @@ -641,9 +746,12 @@ final class AppModel { } } - /// 自动检查:每天至多一次、失败静默、发现新版只点亮管理行的提示, - /// 绝不弹窗打断 —— 更新是「顺便知道」的事,不值得一个模态框。 - /// `defaults write com.tetherkit.app updateCheckDisabled -bool YES` 可关掉。 + /// Automatic check: at most once a day, silent on failure, and a new + /// version only lights up the hint in the management row -- it never + /// interrupts with a dialog, since knowing about an update is a "by the + /// way" matter that does not deserve a modal box. + /// `defaults write com.tetherkit.app updateCheckDisabled -bool YES` + /// turns it off. private func checkForUpdatesQuietly() async { guard let current = UpdateChecker.currentVersion else { return } let defaults = UserDefaults.standard @@ -659,15 +767,17 @@ final class AppModel { availableUpdate = UpdateChecker.isNewer(latest.version, than: current) ? latest : nil } - /// 把查到的最新版记进 defaults —— 明天的启动被节流拦住时,提示不该消失。 + /// Record the newest version found in defaults -- when tomorrow's + /// startup is blocked by the throttle, the hint should not disappear. private func remember(_ release: UpdateChecker.Release) { let defaults = UserDefaults.standard defaults.set(release.version, forKey: Self.updateKnownVersionKey) defaults.set(release.pageURL.absoluteString, forKey: Self.updateKnownPageKey) } - /// 启动时恢复上次查到的新版提示。升级完成后(当前版本 ≥ 记住的版本) - /// 自然失效,不需要任何清理逻辑。 + /// Restore the new-version hint at startup. It expires naturally once + /// the upgrade is done (current version >= the remembered version), so + /// no cleanup logic is needed. private func restoreKnownUpdate() { guard let current = UpdateChecker.currentVersion, let version = UserDefaults.standard.string(forKey: Self.updateKnownVersionKey), @@ -682,8 +792,10 @@ final class AppModel { private static let updateKnownVersionKey = "updateKnownVersion" private static let updateKnownPageKey = "updateKnownPageURL" - /// 取输出的末尾几行给弹窗用 —— 完整输出可能很长,真实原因几乎总在结尾。 - /// 顺手剥掉脚本里的 ANSI 颜色码,弹窗里那是乱码。 + /// Take the last few lines of the output for the dialog -- the full + /// output can be long, and the real reason is almost always at the end. + /// The ANSI color codes from the script are stripped along the way, + /// since in the dialog they are just noise. private static func tail(of output: String, maxLines: Int = 10, maxCharacters: Int = 700) -> String { let plain = output.replacingOccurrences(of: "\u{1B}\\[[0-9;]*m", with: "", @@ -696,40 +808,51 @@ final class AppModel { return kept.isEmpty ? L(.scriptNoOutput) : kept } - /// 带着授权凭据执行一次特权操作,必要时才弹系统授权框。 + /// Run one privileged operation with an authorization credential, + /// popping the system authorization dialog only when needed. /// - /// ★ 为什么缓存令牌 ★ - /// `system.privilege.admin` 的实测参数是 `shared = false`、`timeout = 300`。 - /// `shared = false` 意味着凭据**不跨 AuthorizationRef 共享** —— 每次操作 - /// 新建一个 ref,就必然要用户重新认证一次,于是「连接、配网络、断开」 - /// 会连弹三次框。而 `timeout = 300` 意味着同一个 ref 上的凭据 5 分钟内 - /// 一直有效。 + /// Why the token is cached: + /// The measured parameters of `system.privilege.admin` are + /// `shared = false` and `timeout = 300`. `shared = false` means the + /// credential is **not shared across AuthorizationRefs** -- every + /// operation creates a new ref and therefore requires the user to + /// authenticate again, so "connect, configure the network, disconnect" + /// would pop three dialogs in a row. `timeout = 300` means a + /// credential on the same ref stays valid for 5 minutes. /// - /// 所以缓存令牌复用:第一次操作弹一次框,之后 5 分钟内都不用再弹。 - /// 过期后 helper 的复核会失败并明确告知「这是授权问题」,我们据此丢弃 - /// 缓存、重新弹一次框、把这次操作重试一遍 —— 用户看到的仍然是「操作前 - /// 弹了一次框」,而不是一个莫名其妙的失败。 + /// So the token is cached: the first operation pops one dialog, and + /// none are needed again for 5 minutes. After it expires the helper's + /// re-check fails and says clearly that "this is an authorization + /// problem", and we discard the cache, pop one dialog again and retry + /// the operation -- what the user sees is still "one dialog before + /// the operation", not a baffling failure. /// - /// ★ 为什么不能自己接住凭据再用 ★ - /// 外部形式只是指向 securityd 里那份授权的一把钥匙,不是凭据本身。 - /// AuthorizationRef 一释放,helper 还原时就会报 -60005。所以令牌由 - /// `cachedAuthorization` 持有,并用 `withExtendedLifetime` 保证它活到 - /// XPC 往返结束之后。 + /// Why we cannot catch the credential ourselves and reuse it: + /// The external form is just a key pointing at the authorization held + /// in securityd, not the credential itself. As soon as the + /// AuthorizationRef is released, the helper's re-check reports + /// -60005. So the token is held by `cachedAuthorization`, and + /// `withExtendedLifetime` keeps it alive until the XPC round trip has + /// finished. /// - /// 用户取消时**不**弹错误提示 —— 取消是正常操作,再弹一个「已取消」的框 - /// 只会烦人。 + /// A user cancellation does **not** show an error -- cancelling is a + /// normal outcome, and an extra "cancelled" dialog is just annoying. private func authorized(_ body: @escaping (Data) async throws -> Void) async { - // 第一趟:有缓存就直接用,不打扰用户。 + // First pass: use the cache if there is one, do not bother the + // user. if let cached = cachedAuthorization { - // withExtendedLifetime 不能接 async 闭包,所以用 defer 把令牌钉到 - // 作用域结束 —— 光靠局部 let 不够,ARC 可以在最后一次读 - // externalForm 之后就释放它,而那时 XPC 往返还没回来。 + // withExtendedLifetime cannot take an async closure, so a defer + // pins the token to the end of the scope -- a plain local let + // is not enough, ARC may release it right after the last read + // of externalForm, and the XPC round trip has not come back + // yet. defer { withExtendedLifetime(cached) {} } do { try await body(cached.externalForm) return } catch let failure as HelperClient.Failure where failure.isAuthorizationProblem { - // 凭据过期了。丢掉缓存,往下走「重新授权 + 重试」。 + // The credential expired. Drop the cache and fall through + // to "re-authorize and retry". cachedAuthorization = nil } catch { alertMessage = error.localizedDescription @@ -737,7 +860,8 @@ final class AppModel { } } - // 第二趟:弹框取新凭据,然后执行(或重试)。 + // Second pass: pop the dialog for a fresh credential, then run (or + // retry). do { let token = try AuthorizationBroker.requestAuthorization( prompt: Self.authorizationPrompt) diff --git a/gui/Sources/TetherKitApp/DesignSystem.swift b/gui/Sources/TetherKitApp/DesignSystem.swift index f267f1f..5a7a28f 100644 --- a/gui/Sources/TetherKitApp/DesignSystem.swift +++ b/gui/Sources/TetherKitApp/DesignSystem.swift @@ -1,15 +1,19 @@ import SwiftUI import TetherKitIPC -/// 界面的设计语言。 +/// The design language of the UI. /// -/// 集中定义间距、圆角、颜色与几个复用容器,目的只有一个:**让每一处的视觉决定 -/// 只做一次**。散落在各个 View 里的魔数会在加新面板时慢慢走形,而这类走形没有 -/// 任何自动化手段能发现。 +/// Spacing, corner radii, colors and a few reusable containers are defined +/// in one place for a single purpose: **make every visual decision +/// exactly once**. Magic numbers scattered across the views slowly drift +/// apart as new panels are added, and no automated means can detect that +/// kind of drift. enum Design { - // MARK: - 尺寸 + // MARK: - Sizes - /// 间距阶梯。按 4 的倍数递进 —— 与系统控件的内部留白同源,混排时不会错位。 + /// The spacing ladder. It progresses in multiples of 4 -- the same + /// origin as the internal padding of system controls, so mixed layouts + /// do not misalign. enum Spacing { static let tight: CGFloat = 6 static let small: CGFloat = 10 @@ -18,7 +22,7 @@ enum Design { static let gutter: CGFloat = 12 static let medium: CGFloat = 16 static let large: CGFloat = 24 - /// 卡片内边距。 + /// The card padding. static let section: CGFloat = 16 } @@ -37,10 +41,11 @@ enum Design { static let height: CGFloat = 560 } - // MARK: - 状态色 + // MARK: - State colors - /// 会话状态对应的强调色。整个界面的色彩都由它驱动 —— 用户扫一眼颜色就知道 - /// 现在是什么情况,不需要读文字。 + /// The accent color for a session state. The whole UI is driven by it + /// -- a glance at the color tells the user what is going on, without + /// reading any text. static func accent(for state: RunState) -> Color { switch state { case .idle, .stopped: return .secondary @@ -61,7 +66,7 @@ enum Design { } } - /// 状态圆环里的 SF Symbol。 + /// The SF Symbol inside the status ring. static func statusSymbol(for status: SessionStatus) -> String { switch status.runState { case .idle, .stopped: return "bolt.horizontal" @@ -81,13 +86,15 @@ enum Design { } } -// MARK: - 复用容器 +// MARK: - Reusable containers -/// 卡片容器。所有内容面板都用它包一层,保证圆角、留白、描边一致。 +/// The card container. Every content panel wraps itself in one, so the +/// corner radius, the padding and the border stay consistent. struct Card: View { var title: String? var systemImage: String? - /// 标题右侧的附属视图(刷新按钮、状态徽标等)。 + /// The accessory view to the right of the title (a refresh button, a + /// status badge, and the like). var accessory: AnyView? @ViewBuilder var content: () -> Content @@ -119,8 +126,10 @@ struct Card: View { } .padding(Design.Spacing.section) .frame(maxWidth: .infinity, alignment: .leading) - // 用 material 而不是纯色:它会跟随浅色/深色外观与桌面背景, - // 是 macOS 上「现代」最省力也最不容易做错的一步。 + // Use material rather than a flat color: it follows the + // light/dark appearance and the desktop background, which is the + // least-effort and least error-prone way to look "modern" on + // macOS. .background(.regularMaterial, in: RoundedRectangle(cornerRadius: Design.Radius.card)) .overlay( RoundedRectangle(cornerRadius: Design.Radius.card) @@ -128,7 +137,7 @@ struct Card: View { } } -/// 一格指标:上面是说明,下面是值。 +/// One metric tile: the caption on top, the value below. struct MetricTile: View { let caption: String let value: String @@ -148,7 +157,8 @@ struct MetricTile: View { .foregroundStyle(.secondary) Text(value) - // 等宽数字:数值跳动时字符不会左右抖动。 + // Monospaced digits: the characters do not jitter + // sideways when the value changes. .font(.system(.title3, design: .rounded).weight(.medium)) .monospacedDigit() .foregroundStyle(tint) @@ -159,7 +169,8 @@ struct MetricTile: View { } } -/// 小圆点徽标,用于「链路已连通 / 已断开」这类二元状态。 +/// A small dot badge, for binary states such as "link is up / link is +/// down". struct StatusBadge: View { let text: String let color: Color @@ -179,14 +190,17 @@ struct StatusBadge: View { } } -// MARK: - 格式化 +// MARK: - Formatting -/// 界面上所有数值的格式化都走这里,避免同一个量在不同面板显示成不同样子。 +/// Every number the UI shows is formatted here, so the same quantity +/// never looks different in two panels. enum Format { - /// 速率。自动在 bit/s、Kbps、Mbps、Gbps 之间选单位。 + /// A bit rate. The unit is picked automatically among bit/s, Kbps, Mbps + /// and Gbps. /// - /// 用 bit 而不是 byte:网络吞吐的行业惯例是 bit,和用户在路由器、 - /// 运营商那里看到的口径一致。 + /// Bits rather than bytes: the industry convention for network + /// throughput is bits, which matches what the user sees on a router or + /// at an ISP. static func bitrate(_ bitsPerSecond: Double) -> String { let units: [(threshold: Double, suffix: String, divisor: Double)] = [ (1_000_000_000, "Gbps", 1_000_000_000), @@ -199,27 +213,31 @@ enum Format { return String(format: "%.0f bps", max(0, bitsPerSecond)) } - /// 累计字节数。 + /// A cumulative byte count. static func bytes(_ value: UInt64) -> String { - // ByteCountFormatter 把 0 渲染成 "Zero KB"(它的本地化行为), - // 在一排数字里显得格外突兀,单独处理掉。 + // ByteCountFormatter renders 0 as "Zero KB" (its localized + // behavior), which stands out badly in a row of numbers, so 0 is + // handled separately. guard value > 0 else { return "0 B" } let formatter = ByteCountFormatter() formatter.countStyle = .binary return formatter.string(fromByteCount: Int64(clamping: value)) } - /// 大整数加千分位,便于读「12,345,678 帧」。 + /// Large integers get thousands separators, so "12,345,678 frames" is + /// easy to read. static func count(_ value: UInt64) -> String { let formatter = NumberFormatter() formatter.numberStyle = .decimal return formatter.string(from: NSNumber(value: value)) ?? "\(value)" } - /// 菜单栏用的紧凑速率(如 "12.3M"、"999K")。 + /// The compact bit rate for the menu bar (for example "12.3M", "999K"). /// - /// 菜单栏寸土寸金:单位压成单字母,1 Kbps 以下一律显示 "0K" —— - /// 那点流量在「瞟一眼菜单栏」的语境里就等于没有。 + /// The menu bar has very little room: the unit is squeezed to a single + /// letter, and anything below 1 Kbps always shows as "0K" -- in the + /// "glance at the menu bar" context that much traffic simply does not + /// exist. static func compactBitrate(_ bitsPerSecond: Double) -> String { let value = max(0, bitsPerSecond) if value >= 1_000_000_000 { return String(format: "%.1fG", value / 1_000_000_000) } @@ -244,7 +262,7 @@ enum Format { timeFormatter.string(from: date) } - /// 把秒数渲染成 "1:02:03" / "2:03"。 + /// Render a number of seconds as "1:02:03" / "2:03". static func duration(_ seconds: TimeInterval) -> String { let total = Int(max(0, seconds)) let hours = total / 3600 diff --git a/gui/Sources/TetherKitApp/FinderAlias.swift b/gui/Sources/TetherKitApp/FinderAlias.swift index 94afb93..be1cc8a 100644 --- a/gui/Sources/TetherKitApp/FinderAlias.swift +++ b/gui/Sources/TetherKitApp/FinderAlias.swift @@ -1,26 +1,35 @@ import Foundation import TetherKitIPC -/// 在「应用程序」目录里维护一个指向本 .app 的 Finder 别名,让聚焦搜得到。 +/// Maintain a Finder alias pointing at this .app in the Applications +/// directory, so Spotlight can find it. /// -/// ★ 为什么是别名(bookmark)而不是软链接 ★ -/// Homebrew formula 把 .app 装在 Cellar 里,聚焦不索引那里;指向 App 的 -/// **软链接**聚焦同样不认。Finder 别名是真正的书签文件:聚焦按文件名索引它, -/// 回车即启动目标。用 URL 的 bookmark API(.suitableForBookmarkFile)生成, -/// 不需要 Finder 帮忙、不触发任何权限弹窗。 +/// Why an alias (bookmark) and not a symbolic link: +/// The Homebrew formula installs the .app into the Cellar, and Spotlight +/// does not index that; a **symbolic link** pointing at the App is not +/// recognized by Spotlight either. A Finder alias is a real bookmark +/// file: Spotlight indexes it by file name and Return launches the +/// target. It is created with the URL bookmark API +/// (.suitableForBookmarkFile), which needs no help from Finder and +/// triggers no permission dialog. /// -/// ★ 为什么在首次启动时建,而不是安装时 ★ -/// 实测 brew 连 postinstall 都跑在沙箱里,写 /Applications 会被静默拦下 —— -/// 「装完立刻建」走不通。所以由 App 每次启动顺手校一遍:缺了就补、目标 -/// 漂移(brew upgrade 换了 Cellar 版本路径)就重写。`--install-finder-alias` -/// 模式(建完即退)保留给 CI 冒烟与手动补建。 +/// Why it is created at first launch and not at install time: +/// Measured: brew runs even its postinstall inside a sandbox, and +/// writing /Applications is silently blocked -- so "create it right +/// after installing" does not work. Instead the app checks it on every +/// launch as a side task: add it if missing, rewrite it if the target +/// drifted (brew upgrade changed the Cellar version path). The +/// `--install-finder-alias` mode (create and exit) is kept for the CI +/// smoke test and for manual rebuilds. enum FinderAlias { - /// 别名文件名。不带 .app —— 它是书签文件,不是 bundle; - /// 聚焦显示的名字就是它。 + /// The alias file name. Without .app -- it is a bookmark file, not a + /// bundle; the name Spotlight shows is exactly this. private static let aliasName = "TetherKit" - /// 确保别名存在且指向 bundleURL。返回一句人话结果(--install-finder-alias - /// 模式会原样打印)。失败不抛错 —— 别名是锦上添花,不该拦住任何流程。 + /// Make sure the alias exists and points at bundleURL. Returns a + /// plain-language result (the --install-finder-alias mode prints it + /// verbatim). Failures do not throw -- the alias is a bonus and must + /// not block any flow. @discardableResult static func ensure(for bundleURL: URL) -> String { guard bundleURL.pathExtension == "app" else { @@ -31,8 +40,10 @@ enum FinderAlias { return L(.aliasSkippedAlreadyInApplications) } - // 先试全局 /Applications(admin 组可写,不需要 root); - // 写不进去(受管机器、非管理员账户)退回 ~/Applications —— 聚焦同样索引。 + // Try the global /Applications first (writable by the admin + // group, no root needed); if it cannot be written (a managed + // machine, a non-admin account) fall back to ~/Applications -- + // Spotlight indexes that one too. let candidates = [ URL(fileURLWithPath: "/Applications", isDirectory: true), FileManager.default.homeDirectoryForCurrentUser @@ -50,8 +61,9 @@ enum FinderAlias { return L(.aliasFailed) } - /// 在指定目录里建立/修复别名。位置被非别名文件占用时返回 nil(换下一个 - /// 候选目录),其余失败抛错。 + /// Create or repair the alias in the given directory. Returns nil + /// when the name is taken by a file that is not an alias (try the next + /// candidate directory); other failures throw. private static func ensure(in directory: URL, target: URL) throws -> String? { let aliasURL = directory.appendingPathComponent(aliasName) let path = aliasURL.path @@ -59,7 +71,8 @@ enum FinderAlias { if FileManager.default.fileExists(atPath: path) { let isAlias = (try? aliasURL.resourceValues(forKeys: [.isAliasFileKey]))? .isAliasFile ?? false - // 不是我们的别名(用户自己拷了个同名文件?)就绝不动它。 + // Not our alias (the user copied a file with the same + // name?) -- never touch it. guard isAlias else { return nil } if let resolved = try? URL(resolvingAliasFileAt: aliasURL, @@ -67,7 +80,8 @@ enum FinderAlias { resolved.standardizedFileURL.path == target.path { return L(.aliasAlreadyPresent, path) } - // 目标漂移(升级换了路径)—— 重写。 + // The target drifted (an upgrade changed the path) -- + // rewrite it. } let bookmark = try target.bookmarkData(options: .suitableForBookmarkFile, diff --git a/gui/Sources/TetherKitApp/HelperClient.swift b/gui/Sources/TetherKitApp/HelperClient.swift index 6c27388..e617337 100644 --- a/gui/Sources/TetherKitApp/HelperClient.swift +++ b/gui/Sources/TetherKitApp/HelperClient.swift @@ -1,21 +1,28 @@ import Foundation import TetherKitIPC -/// 与 tetherkit-helper 通信的客户端。 +/// The client that talks to tetherkit-helper. /// -/// 把「基于 reply block 的 XPC」包成 async/await。看着琐碎,但有一处必须小心: -/// NSXPCConnection 的错误处理块与方法的 reply 块**可能都被调用**(比如请求已 -/// 发出、连接随后断开),而 CheckedContinuation 被 resume 两次是直接崩溃。 -/// 因此每次调用都用 ContinuationGuard 包一层,保证只兑现一次。 +/// It wraps "reply block based XPC" into async/await. It looks fiddly, but +/// one thing has to be handled with care: the NSXPCConnection error block +/// and the method's reply block **may both be called** (the request went +/// out and the connection dropped right after, for instance), and resuming +/// a CheckedContinuation twice is an immediate crash. So every call wraps +/// the continuation in a ContinuationGuard, which guarantees the result is +/// delivered exactly once. final class HelperClient { enum Failure: LocalizedError { - /// 连不上 —— 最常见的原因是 helper 还没安装。 + /// Cannot connect -- the most common cause is that the helper is + /// not installed yet. case unreachable(String) - /// helper 明确返回了错误。 + /// The helper explicitly returned an error. case helper(String) - /// helper 复核授权没通过 —— 多半是凭据过期了,重新弹框再来一次就行。 + /// The helper's authorization re-check did not pass -- most likely + /// the credential expired, so popping the dialog again and retrying + /// is enough. case authorizationRejected(String) - /// 应答解不出来(两端版本不一致)。 + /// The reply cannot be decoded (the two sides run different + /// versions). case malformedResponse var errorDescription: String? { @@ -34,17 +41,20 @@ final class HelperClient { return false } - /// 是否值得「重新取一次授权再试」。 + /// Whether it is worth fetching a fresh authorization and trying + /// again. var isAuthorizationProblem: Bool { if case .authorizationRejected = self { return true } return false } } - /// 保证一个 continuation 只被兑现一次。 + /// Guarantees that a continuation is delivered exactly once. /// - /// XPC 的错误块和 reply 块存在都被调用的可能,而重复 resume 会崩溃 —— - /// 这个坑只在「请求发出后连接才断」这种时序上出现,很难靠测试撞到。 + /// XPC's error block and reply block can both be called, and resuming + /// twice crashes -- this trap only shows up with the timing "the + /// connection drops after the request went out", which is hard to hit + /// with a test. private final class ContinuationGuard: @unchecked Sendable { private let lock = NSLock() private var continuation: CheckedContinuation? @@ -76,7 +86,7 @@ final class HelperClient { cachedConnection?.invalidate() } - /// 取(必要时创建)到 helper 的连接。 + /// Get (creating it if needed) the connection to the helper. private func connection() -> NSXPCConnection { connectionLock.withLock { if let existing = cachedConnection { @@ -86,8 +96,9 @@ final class HelperClient { options: .privileged) created.remoteObjectInterface = NSXPCInterface(with: TetherKitHelperProtocol.self) - // 连接断掉后必须丢弃缓存,否则后续调用会一直打在一条死连接上, - // 表现为「helper 明明装好了却一直连不上」。 + // The cached connection has to be discarded once it breaks, + // otherwise every later call hits a dead connection and it + // looks like "the helper is installed but never connects". let invalidate: @Sendable () -> Void = { [weak self] in self?.connectionLock.withLock { self?.cachedConnection = nil } } @@ -100,7 +111,8 @@ final class HelperClient { } } - /// 发一次调用。`body` 拿到代理与守卫,负责发起请求并兑现结果。 + /// Issue one call. `body` receives the proxy and the guard, and is + /// responsible for starting the request and delivering the result. private func invoke( _ body: @escaping (TetherKitHelperProtocol, ContinuationGuard) -> Void ) async throws -> T { @@ -117,7 +129,8 @@ final class HelperClient { } } - /// 「返回一个 Codable 或一条错误」这种应答的通用处理。 + /// The shared handling of a reply that is "one Codable value or one + /// error". private func decode(_ type: T.Type, data: Data?, message: String?, into guarded: ContinuationGuard) { if let message { @@ -131,7 +144,7 @@ final class HelperClient { guarded.resume(returning: value) } - // MARK: - 探测(不需要授权) + // MARK: - Probing (no authorization required) func helperVersion() async throws -> String { try await invoke { proxy, guarded in @@ -184,16 +197,19 @@ final class HelperClient { } } - /// 把界面语言告诉 helper。**失败静默吞掉** —— 语言没同步上只是日志卡里 - /// 混了另一种语言,不该让它把「连不上 helper」的错误弹给用户(正常启动 - /// 顺序里 helper 可能还没装)。 + /// Tell the helper the interface language. **Failures are swallowed + /// silently** -- a language that did not synchronize only means the log + /// card mixes in another language, and that must not surface to the + /// user as a "cannot connect to helper" error (in the normal startup + /// order the helper may not be installed yet). func setLanguage(_ language: Language) async { _ = try? await invoke { proxy, guarded in proxy.setLanguage(language.rawValue) { guarded.resume(returning: ()) } } } - // MARK: - 特权操作(需要授权凭据) + // MARK: - Privileged operations (an authorization credential is + // required) func startSession(authorization: Data, configuration: SessionConfiguration) async throws { let payload = try JSONEncoder().encode(configuration) diff --git a/gui/Sources/TetherKitApp/HelperInstaller.swift b/gui/Sources/TetherKitApp/HelperInstaller.swift index fc77201..9bd59b3 100644 --- a/gui/Sources/TetherKitApp/HelperInstaller.swift +++ b/gui/Sources/TetherKitApp/HelperInstaller.swift @@ -2,44 +2,64 @@ import Foundation import Security import TetherKitIPC -/// 把 .app 内嵌的特权组件载荷安装成 LaunchDaemon,或把装好的卸干净 —— -/// 「一键安装 / 卸载」按钮背后的全部机制。真正的步骤在载荷里的 -/// install-helper.sh / uninstall-helper.sh,这里只负责两件事:找到、以 root 跑。 +/// Install the privileged helper payload embedded in the .app as a +/// LaunchDaemon, or uninstall it cleanly again -- the whole mechanism +/// behind the "one-click install / uninstall" buttons. The real steps +/// live in the payload's install-helper.sh / uninstall-helper.sh; this +/// file does only two things: find them, and run them as root. /// -/// ★ 为什么是 AuthorizationExecuteWithPrivileges(AEWP)★ +/// Why AuthorizationExecuteWithPrivileges (AEWP): /// -/// 目标只有一个:以 root 跑一遍安装脚本。系统里能做这件事的口子: +/// There is exactly one goal: run the install script once as root. The +/// system offers these ways of doing that: /// -/// * SMJobBless / SMAppService —— 靠代码签名 DR 双向绑定,源码分发下每台 -/// 机器的 cdhash 都不同,走不通(docs/GUI-SPIKE.md 第 3.3 节); -/// * osascript "with administrator privileges" —— 能用,但它自建授权会话, -/// 与 App 缓存的 AuthorizationRef(5 分钟窗口)完全接不上,必然多弹框; -/// * AEWP —— 参数就是 AuthorizationRef:缓存令牌还有效就一次框都不弹, -/// 权利缺失时由它按 system.privilege.admin 弹标准授权框。与现有信任 -/// 模型(AuthorizationRef 复核,见 Authorization.swift)无缝衔接。 +/// * SMJobBless / SMAppService -- they bind the App and the helper +/// bidirectionally through the code signing DR, and under source +/// distribution every machine has a different cdhash, so they cannot +/// work (docs/GUI-SPIKE.md section 3.3); +/// * osascript "with administrator privileges" -- it works, but it creates +/// its own authorization session, which cannot connect to the +/// AuthorizationRef the App caches (a 5 minute window) at all, so it +/// necessarily pops extra dialogs; +/// * AEWP -- its parameter is the AuthorizationRef: while the cached +/// token is still valid it pops no dialog at all, and when the right +/// is missing it raises the standard authorization dialog under +/// system.privilege.admin. It fits the existing trust model +/// (AuthorizationRef re-check, see Authorization.swift) seamlessly. /// -/// AEWP 自 10.7 起标记废弃,但 macOS 26 上实测符号仍可解析、它的 setuid -/// 执行载体 /usr/libexec/security_authtrampoline 也仍在。这里用 dlsym 在 -/// 运行时解析而不是直接链接:将来系统真把它移除时,用户看到的是本页降级成 -/// 「请用终端安装」,而不是 App 在 dyld 绑定阶段直接起不来。 +/// AEWP has been marked deprecated since 10.7, but on macOS 26 the +/// symbol still resolves in practice and its setuid carrier +/// /usr/libexec/security_authtrampoline is still there. It is resolved +/// here with dlsym at runtime rather than linked directly: if a future +/// system really removes it, the user sees this page degrade into +/// "please install from a terminal" instead of the app failing to +/// start at the dyld binding stage. /// -/// ★ 为什么 AEWP 的对象是 helper 二进制而不是安装脚本 ★ +/// Why the target of AEWP is the helper binary and not the install script: /// -/// AEWP 给子进程的凭据是 euid=0 / ruid=普通用户,而 bash 在 euid ≠ ruid -/// 且未带 -p 时会把 euid 降回 ruid —— 直接 AEWP 脚本,脚本开头的 root 检查 -/// 必然失败(真机踩过)。所以这里拉起的是载荷里 helper 二进制的 --install -/// 模式:它先 setuid(0) 把凭据归一化成与 sudo 完全相同的真 root,把 stderr -/// 并进 stdout(AEWP 的管道只接 stdout),再 exec 同目录的 install-helper.sh。 -/// 见 TetherKitHelper/InstallerMode.swift。 +/// The credential AEWP gives the child process is euid=0 / ruid=the +/// ordinary user, and bash drops euid back to ruid when euid != ruid +/// and -p is not given -- running a script through AEWP directly makes +/// the root check at the top of that script fail for sure (hit on a +/// real device). So what gets launched here is the --install mode of +/// the helper binary in the payload: it first setuid(0)s, normalizing +/// the credential into exactly the same true root as sudo, merges +/// stderr into stdout (AEWP's pipe only takes stdout), and then execs +/// the install-helper.sh in the same directory. +/// See TetherKitHelper/InstallerMode.swift. /// -/// ★ 为什么成败不看退出码 ★ +/// Why success and failure are not read from the exit code: /// -/// AEWP 既不给子进程 pid 也不给退出码,只有一根连到子进程 stdout 的管道。 -/// 所以这里只把「脚本跑完了」和「它说了什么」交出去;真正的成败由调用方 -/// (AppModel)用一次真实的 XPC 往返判定 —— 「能连上匹配版本的 helper」 -/// 本来就比任何退出码都更接近事实。输出只在失败时给用户看原因。 +/// AEWP gives neither the child's pid nor its exit code, only a pipe +/// connected to the child's stdout. So this file hands out only "the +/// script finished" and "what it said"; the real verdict is reached +/// by the caller (AppModel) with one real XPC round trip -- "can +/// connect to a helper of the matching version" is inherently closer +/// to the truth than any exit code. The output is shown to the user +/// only on failure, and then only to explain why. enum HelperInstaller { - /// 要执行的维护动作,对应 helper 二进制的命令行参数与载荷里的脚本。 + /// The maintenance action to perform, matching the helper binary's + /// command line argument and the script in the payload. enum Action { case install case uninstall @@ -53,12 +73,16 @@ enum HelperInstaller { } enum Failure: LocalizedError { - /// 找不到内嵌载荷。典型场景:`swift run` 直接跑的裸可执行文件 —— - /// 只有 build-gui.sh 组装出的 .app 才带 Contents/Library/HelperTools。 + /// The embedded payload cannot be found. Typical case: the bare + /// executable run directly by `swift run` -- only the .app + /// assembled by build-gui.sh carries + /// Contents/Library/HelperTools. case payloadMissing - /// 运行时解析不到 AEWP(未来某个 macOS 移除了它)。 + /// AEWP cannot be resolved at runtime (some future macOS removed + /// it). case executorUnavailable - /// AEWP 返回错误(用户取消已单独成 case)。 + /// AEWP returned an error (a user cancellation is a separate + /// case). case launchFailed(OSStatus) case userCancelled @@ -76,34 +100,42 @@ enum HelperInstaller { } } - /// 内嵌载荷目录(平铺放着 helper 二进制、dylib、plist 与安装脚本)。 - /// 不是从组装好的 .app 里跑起来时为 nil。 + /// The embedded payload directory (the helper binary, the dylib, the + /// plist and the install scripts sit side by side in it). + /// nil when not running from an assembled .app. static var payloadDirectory: URL? { let directory = Bundle.main.bundleURL .appendingPathComponent("Contents/Library/HelperTools", isDirectory: true) - // 二进制(AEWP 的执行对象)与两个脚本(真正的安装/卸载逻辑)缺一不可。 + // The binary (AEWP's execution target) and the two scripts (the + // actual install/uninstall logic) are all required. let required = [helperBinaryName, installScriptName, uninstallScriptName] .map { directory.appendingPathComponent($0).path } let complete = required.allSatisfy { FileManager.default.isExecutableFile(atPath: $0) } return complete ? directory : nil } - /// 进入安装流程前的快速自检:载荷在不在、AEWP 还在不在。 + /// A quick preflight before entering the install flow: is the payload + /// there, is AEWP still there. /// - /// 单独暴露是为了让调用方在**弹授权框之前**就发现装不了 —— 把用户的密码 - /// 要来了再说「装不了」是最差的次序。 + /// It is exposed separately so the caller can find out that the + /// install is impossible **before the authorization dialog pops up** -- + /// asking for the user's password and only then saying "this cannot + /// be installed" is the worst possible order. static func preflightError() -> Failure? { if payloadDirectory == nil { return .payloadMissing } if loadExecutor() == nil { return .executorUnavailable } return nil } - /// 以 root 执行载荷里的安装 / 卸载脚本,返回其全部输出(stdout 与 stderr - /// 合流)。 + /// Run the install / uninstall script from the payload as root and + /// return its whole output (stdout and stderr merged). /// - /// 会阻塞在「可能弹出的授权框」与「脚本执行」上,所以整体挪到后台队列; - /// 返回即代表脚本已经跑完(管道读到 EOF),**不代表动作成功** —— 见类型 - /// 注释里「为什么成败不看退出码」。 + /// It blocks on "the authorization dialog that may pop up" and on "the + /// script execution", so the whole thing is moved to a background + /// queue; returning means the script has run to completion (the pipe + /// reached EOF), and it does **not** mean the action succeeded -- see + /// "Why success and failure are not read from the exit code" in the + /// type notes. static func run(_ action: Action, with token: AuthorizationToken) async throws -> String { guard let payload = payloadDirectory else { throw Failure.payloadMissing } guard let execute = loadExecutor() else { throw Failure.executorUnavailable } @@ -112,7 +144,9 @@ enum HelperInstaller { return try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { continuation.resume(with: Result { - // 令牌必须活到 AEWP 返回、脚本跑完之后 —— 授权在它手里。 + // The token has to stay alive until AEWP returns and + // the script has run -- the authorization lives in + // it. try withExtendedLifetime(token) { try run(execute, token: token, installer: installer, flag: action.flag) @@ -122,15 +156,17 @@ enum HelperInstaller { } } - // MARK: - 实现 + // MARK: - Implementation private static let installScriptName = "install-helper.sh" private static let uninstallScriptName = "uninstall-helper.sh" - /// 载荷里 helper 二进制的文件名,与 LaunchDaemon 的 Label 同名。 + /// The file name of the helper binary in the payload, identical to the + /// LaunchDaemon's Label. private static let helperBinaryName = HelperConstants.machServiceName - /// AEWP 的 C 原型。倒数第二个参数是 `char *const *`(NULL 结尾的 argv, - /// 不含 argv[0] —— trampoline 会把工具路径填成 argv[0])。 + /// The C prototype of AEWP. The second to last parameter is + /// `char *const *` (a NULL-terminated argv that does not contain + /// argv[0] -- the trampoline fills the tool path into argv[0]). private typealias ExecuteFunction = @convention(c) ( AuthorizationRef, UnsafePointer, @@ -140,9 +176,11 @@ enum HelperInstaller { ) -> OSStatus private static func loadExecutor() -> ExecuteFunction? { - // RTLD_DEFAULT:Security.framework 已经由本进程链接,符号一定在全局 - // 命名空间里(若还存在的话)。Darwin 模块没把这个常量导出给 Swift, - // 只能按 dlfcn.h 里的定义写死 -2。 + // RTLD_DEFAULT: Security.framework is already linked by this + // process, so the symbol is definitely in the global namespace (if + // it still exists at all). The Darwin module does not export this + // constant to Swift, so it has to be hardcoded to -2 as defined + // in dlfcn.h. guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "AuthorizationExecuteWithPrivileges") else { return nil } return unsafeBitCast(symbol, to: ExecuteFunction.self) @@ -152,8 +190,10 @@ enum HelperInstaller { token: AuthorizationToken, installer: String, flag: String) throws -> String { - // 执行对象是 helper 二进制的安装/卸载模式,不是脚本 —— 原因见类型 - // 注释。stderr 合流也由那边做(dup2),这里只管拉起与收输出。 + // The execution target is the helper binary's install/uninstall + // mode, not the script -- the reason is in the type notes. The + // stderr merge is done there as well (dup2); here we only launch + // it and collect the output. var pipe: UnsafeMutablePointer? let status = token.withReference { reference in withArgv([flag]) { argv in @@ -174,9 +214,11 @@ enum HelperInstaller { guard let pipe else { return "" } defer { fclose(pipe) } - // 直接读底层 fd 到 EOF —— EOF 即子进程结束(LaunchDaemon 的 stdio 由 - // launchd 按 plist 里的 StandardOutPath 单独给,不会继承这根管道, - // 所以不存在「孙进程拖着管道不放」的悬挂)。 + // Read the underlying fd straight to EOF -- EOF means the child + // has finished (the LaunchDaemon's stdio is given separately by + // launchd through the StandardOutPath in the plist and does not + // inherit this pipe, so there is no hang of the "grandchild keeps + // the pipe open" kind). var output = Data() var buffer = [UInt8](repeating: 0, count: 4096) let descriptor = fileno(pipe) @@ -193,7 +235,8 @@ enum HelperInstaller { return String(decoding: output, as: UTF8.self) } - /// 把 Swift 字符串数组变成 NULL 结尾的 C argv,喂给 body。 + /// Turn a Swift string array into a NULL-terminated C argv and feed it + /// to body. private static func withArgv( _ arguments: [String], _ body: (UnsafePointer?>) -> T diff --git a/gui/Sources/TetherKitApp/TetherKitApp.swift b/gui/Sources/TetherKitApp/TetherKitApp.swift index 8f58f14..53d6853 100644 --- a/gui/Sources/TetherKitApp/TetherKitApp.swift +++ b/gui/Sources/TetherKitApp/TetherKitApp.swift @@ -2,22 +2,28 @@ import AppKit import SwiftUI import TetherKitIPC -/// TetherKit 的图形界面入口。 +/// The graphical interface entry point of TetherKit. /// -/// App 以**普通用户身份**运行,不需要任何特权。所有需要 root 的事(建虚拟网卡、 -/// 开 BPF、配 IP)都交给 tetherkit-helper,每次调用附带一份用户确认过的授权 -/// 凭据。详见 docs/GUI-ARCHITECTURE.md。 +/// The app runs as an **ordinary user** and needs no privileges of its own. +/// Everything that requires root (creating the virtual interface, opening +/// BPF, configuring the IP) is handed to tetherkit-helper, each call +/// carrying an authorization credential the user confirmed. See +/// docs/GUI-ARCHITECTURE.md. /// -/// ★ 后台运行模式 ★ +/// Background running mode: /// -/// 关闭主窗口不退出:App 退到「仅菜单栏」(accessory 激活策略,程序坞图标 -/// 一并隐藏),菜单栏项继续显示实时速率。会话本来就跑在 helper 里,App 的 -/// 存在与否都不影响它 —— 但只要 App 活着,用户就有一个随时能看一眼的入口。 +/// Closing the main window does not quit: the app drops to "menu bar +/// only" (the accessory activation policy hides the dock icon too), and +/// the menu bar item keeps showing live rates. The session runs in the +/// helper anyway, so whether the app exists does not affect it -- but as +/// long as the app lives, the user has an entry point they can glance at +/// any time. /// -/// 轮询在窗口关闭后**不**停(菜单栏靠它喂数据),只是在「窗口关着且会话 -/// 没跑」时放慢到 2 秒一次,见 AppModel.pollDelay。 +/// Polling does **not** stop when the window closes (the menu bar feeds +/// on it); it only slows down to once every 2 seconds while "the window +/// is closed and no session is running", see AppModel.pollDelay. /// -/// ★ Window ownership ★ +/// Window ownership /// /// The main window is an AppKit-managed window (`MainWindowManager`): /// a plain NSWindow (fullSizeContentView, transparent, title hidden) @@ -30,10 +36,14 @@ struct TetherKitApplication: App { @NSApplicationDelegateAdaptor(TetherKitAppDelegate.self) private var appDelegate init() { - // CI 冒烟与手动补建用的模式:建完 Finder 别名即退,不进 GUI。 - // 恒 exit 0 —— 别名建不上(受管机器之类)不该把流程判失败,结果打给 - // stdout。(brew 的 postinstall 也在沙箱里、写不了 /Applications —— - // 实测确认 —— 所以正常安装路径靠下面的首次启动自动建立。) + // The mode used by the CI smoke test and for manual rebuilds: + // create the Finder alias and exit, never entering the GUI. + // Always exit 0 -- failing to create the alias (a managed machine + // and the like) must not fail the flow, and the result goes to + // stdout. (The brew postinstall also runs in a sandbox and cannot + // write /Applications -- confirmed by measurement -- so the normal + // install path relies on the automatic creation at first launch + // below.) if CommandLine.arguments.dropFirst().contains("--install-finder-alias") { print(FinderAlias.ensure(for: Bundle.main.bundleURL)) exit(0) @@ -48,8 +58,9 @@ struct TetherKitApplication: App { NSApplication.shared.setActivationPolicy(.regular) } TetherKitAppDelegate.launchModel = model - // 正常启动:后台顺手校一遍 —— 缺了就补,brew upgrade 换了 Cellar - // 路径后目标漂移也会被重写。 + // Normal launch: check it in the background as a side task -- add + // it if missing, and rewrite it if the target drifted after brew + // upgrade changed the Cellar path. let bundleURL = Bundle.main.bundleURL Task.detached(priority: .utility) { FinderAlias.ensure(for: bundleURL) @@ -60,7 +71,7 @@ struct TetherKitApplication: App { // Persistent menu bar item: icon + live rate. // The .window style gives it a small panel, which can hold a // Language submenu and live values that menu items cannot. - // Commands live on this scene — there is no Window scene + // Commands live on this scene -- there is no Window scene // anymore; the main window belongs to AppKit (see // MainWindowManager). MenuBarExtra { @@ -70,34 +81,42 @@ struct TetherKitApplication: App { } .menuBarExtraStyle(.window) .commands { - // 「新建窗口」对单窗口应用没有意义,去掉免得用户点了没反应。 + // "New Window" makes no sense for a single-window app; drop + // it so the user does not click something that does nothing. CommandGroup(replacing: .newItem) {} - // ⌘W: no Window scene means no built-in system Close, so + // Cmd-W: no Window scene means no built-in system Close, so // this menu item hides the main window and drops to // menu-bar mode (same as the red close button). CommandGroup(after: .newItem) { FileMenuItems(model: model) } - // 「检查更新…」放在 App 菜单「关于」下面的惯例位置。 - // 只查不换 —— 原因见 UpdateChecker.swift 的类型注释。 + // "Check for Updates..." goes in the customary place under + // "About" in the app menu. It only checks and never replaces -- + // the reason is in the type notes of UpdateChecker.swift. CommandGroup(after: .appInfo) { AppMenuItems(model: model) } } } } -/// App 菜单里 TetherKit 自己的那几项。 +/// TetherKit's own items in the app menu. /// -/// ★ 为什么要包成一个 View,而不是把 Button 直接摊在 CommandGroup 里 ★ +/// Why this is wrapped in a View instead of spreading the Buttons directly +/// inside the CommandGroup: /// -/// 摊开写的话,每个 Button 的标签各自是一个独立的表达式,SwiftUI 只会在 -/// 「它依赖的东西变了」时重算。而文案来自全局查表,`L(.menuCheckForUpdates)` -/// 不依赖任何 @Observable 属性 —— 于是切完语言,语言菜单自己变了(它读了 -/// `languagePreference`),旁边的「检查更新…」却还是旧语言。实测确认过。 +/// Spread out, each Button's label is an independent expression, and +/// SwiftUI only re-evaluates it when "something it depends on changed". +/// The strings come from a global table lookup, so +/// `L(.menuCheckForUpdates)` depends on no @Observable property -- after +/// a language switch the language menu updated itself (it reads +/// `languagePreference`) while the "Check for Updates..." next to it +/// kept the old language. Confirmed by measurement. /// -/// 包成一个 View 并在体里读一次 `languageRevision`,整组就一起重算了。 +/// Wrapping the group in a View and reading `languageRevision` once in +/// its body re-evaluates the whole group together. private struct AppMenuItems: View { var model: AppModel var body: some View { - // 读它就是为了建立依赖,值本身用不上。 + // Read it to establish the dependency; the value itself is not + // used. let _ = model.languageRevision Button(L(.menuCheckForUpdates)) { @@ -132,7 +151,7 @@ private struct LanguageMenu: View { } } -/// File ▸ Close Window (⌘W). +/// File > Close Window (Cmd-W). /// /// Same reason as AppMenuItems above: a flat Button in a CommandGroup /// picks its label from the global string table, which depends on no @@ -160,13 +179,17 @@ struct MainWindowRoot: View { ContentView(model: model) .frame(width: Design.Window.width, height: Design.Window.height) .handleSelfCaptureRequest() - // 文案来自全局查表,改语言不会让任何 @Observable 属性「看起来」变了, - // SwiftUI 因此不会重算视图体。用一个显式的版本号做 identity, - // 强制整棵树重建 —— 语言切换是极低频动作,这点代价无所谓。 + // The strings come from a global table lookup, so changing the + // language makes no @Observable property "look" changed and + // SwiftUI therefore does not re-evaluate the view body. An + // explicit revision number is used as the identity to force the + // whole tree to rebuild -- a language switch is an extremely + // rare action, so the cost does not matter. .id(model.languageRevision) .task { - // 轮询在这里启动(幂等)。窗口关闭后它继续跑 —— - // 这正是菜单栏能实时更新的前提。 + // Polling starts here (idempotent). It keeps running + // after the window closes -- that is exactly what lets the + // menu bar update in real time. model.start() } } @@ -201,7 +224,7 @@ final class TetherKitAppDelegate: NSObject, NSApplicationDelegate { return true } - /// Never quit just because the last window closed — the app is a + /// Never quit just because the last window closed -- the app is a /// menu-bar accessory by design. Close hides the window; the /// menu-bar item stays live. func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { diff --git a/gui/Sources/TetherKitApp/UpdateChecker.swift b/gui/Sources/TetherKitApp/UpdateChecker.swift index 2100912..affcc66 100644 --- a/gui/Sources/TetherKitApp/UpdateChecker.swift +++ b/gui/Sources/TetherKitApp/UpdateChecker.swift @@ -1,23 +1,30 @@ import Foundation import TetherKitIPC -/// 到 GitHub Releases 查有没有更新的版本。 +/// Ask GitHub Releases whether a newer version exists. /// -/// ★ 只「检查 + 引导」,绝不自动下载替换 ★ -/// 本项目免证书分发(ad-hoc 签名)。自更新(Sparkle 那类)要下载新 .app 替换 -/// 自己,下载物带 quarantine,替换完的 App 会被 Gatekeeper 直接拦死 —— -/// 和不能走 Cask 是同一堵墙。真正的更新通道是 `brew upgrade` 或源码重编, -/// App 只负责发现新版、把命令递到用户手边。 +/// Only "check and point the way", never download and replace: +/// This project is distributed without a certificate (ad-hoc signing). +/// A self-updater (Sparkle and the like) has to download a new .app and +/// replace itself; the download carries a quarantine attribute and the +/// replaced app is blocked outright by Gatekeeper -- the same wall that +/// rules out a Cask. The real update channel is `brew upgrade` or a +/// rebuild from source; the app only discovers a new version and hands +/// the command to the user. /// -/// ★ 隐私 ★ -/// 只请求 GitHub 的公开 REST API(releases/latest),不携带任何本机信息。 -/// 自动检查每天至多一次,失败静默; -/// `defaults write com.tetherkit.app updateCheckDisabled -bool YES` 可彻底关掉。 +/// Privacy: +/// It only queries GitHub's public REST API (releases/latest) and +/// carries no information about this machine. The automatic check runs +/// at most once a day and is silent on failure; +/// `defaults write com.tetherkit.app updateCheckDisabled -bool YES` turns +/// it off completely. enum UpdateChecker { struct Release: Equatable { - /// 去掉 v 前缀后的版本号,如 "0.2.0"。 + /// The version number with the v prefix removed, for example + /// "0.2.0". let version: String - /// Release 页,引导用户去看更新说明 / 下载。 + /// The release page, where the user reads the release notes or + /// downloads. let pageURL: URL } @@ -38,13 +45,16 @@ enum UpdateChecker { } } - /// 当前 App 的版本号,来自 Info.plist(build-gui.sh 从 CMakeLists 注入)。 - /// `swift run` 的裸可执行文件没有 bundle,返回 nil —— 开发构建不做检查。 + /// The current app's version number, from Info.plist (build-gui.sh + /// injects it from CMakeLists). The bare executable from `swift run` + /// has no bundle and returns nil -- a development build does not + /// check. static var currentVersion: String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String } - /// 取最新发布。404 = 从未发布过;其余非 200 一律按失败处理。 + /// Fetch the latest release. 404 = never released; every other + /// non-200 is treated as a failure. static func fetchLatestRelease() async throws -> Release { var request = URLRequest(url: endpoint) request.timeoutInterval = 10 @@ -60,8 +70,10 @@ enum UpdateChecker { return Release(version: normalize(payload.tagName), pageURL: pageURL) } - /// candidate 是否比 current 新。逐段数值比较,缺位按 0,非数字段按 0 —— - /// 解析不了宁可判「不新」,也不要为一个畸形 tag 弹更新提示。 + /// Whether candidate is newer than current. The segments are compared + /// numerically, a missing segment counts as 0 and a non-numeric one as + /// 0 -- an unparseable version is better judged "not new" than worth + /// an update popup for a malformed tag. static func isNewer(_ candidate: String, than current: String) -> Bool { let lhs = components(of: candidate) let rhs = components(of: current) @@ -73,7 +85,7 @@ enum UpdateChecker { return false } - // MARK: - 实现 + // MARK: - Implementation private static let endpoint = URL(string: "https://api.github.com/repos/justsrc/TetherKit/releases/latest")! @@ -88,7 +100,7 @@ enum UpdateChecker { } } - /// 去掉 tag 常见的 v/V 前缀。 + /// Remove the v/V prefix that tags commonly carry. private static func normalize(_ tag: String) -> String { var tag = tag if tag.hasPrefix("v") || tag.hasPrefix("V") { tag.removeFirst() } diff --git a/gui/Sources/TetherKitApp/Views/ContentView.swift b/gui/Sources/TetherKitApp/Views/ContentView.swift index c1ba927..8b98c8d 100644 --- a/gui/Sources/TetherKitApp/Views/ContentView.swift +++ b/gui/Sources/TetherKitApp/Views/ContentView.swift @@ -75,10 +75,12 @@ struct ContentView: View { .scrollBounceBehavior(.basedOnSize) } - /// 「检查更新」弹窗的正文。 + /// The body of the "Check for Updates..." popup. /// - /// brew 命令名与 tap 里实际的 GUI formula(tetherkit)一致 —— CLI 更名 - /// tetherkit-cli 时这个名字让给了 GUI;手动构建的用户按第二句走。 + /// The brew command name matches the GUI formula in the tap + /// (tetherkit) -- when the CLI was renamed to tetherkit-cli this name + /// went to the GUI; users who build by hand follow the second + /// sentence. private var updateCheckDescription: String { switch model.updateCheckResult { case .upToDate(let current): @@ -112,7 +114,7 @@ struct ContentView: View { } } -/// 正在探测 helper 时的占位。 +/// The placeholder shown while the helper is being probed. private struct ProbingCard: View { var body: some View { Card { @@ -128,10 +130,12 @@ private struct ProbingCard: View { } } -/// helper 没装时的引导。 +/// The guide shown when the helper is not installed. /// -/// 这是新用户最可能撞上的一屏,所以把「为什么需要」说清楚之后直接给一个 -/// 「安装」按钮 —— 弹一次系统授权框就装好。终端方案降级成折叠里的备选。 +/// This is the screen a new user is most likely to hit, so after +/// explaining "why it is needed" it goes straight to an "Install" button +/// -- one system authorization dialog and the helper is in. The terminal +/// route is demoted to a fallback inside a disclosure group. private struct HelperMissingCard: View { var model: AppModel let reason: String @@ -163,10 +167,12 @@ private struct HelperMissingCard: View { } } -/// 装着的 helper 与当前 App 的 XPC 接口对不上。 +/// The installed helper's XPC interface does not match the current app. /// -/// 单独一屏而不是复用「没安装」:这两种情况下用户要做的事不一样,而且如果不 -/// 明说,症状会表现成调用卡住或直接崩溃 —— 那是最难自查的一类问题。 +/// A screen of its own rather than reusing "not installed": the user has +/// to do different things in the two cases, and unless it is spelled out +/// the symptom shows up as calls hanging or crashing outright -- the +/// hardest kind of problem to diagnose on one's own. private struct HelperOutdatedCard: View { var model: AppModel let installed: Int @@ -189,10 +195,14 @@ private struct HelperOutdatedCard: View { } } -/// 「一键安装」按钮 + 终端备选方案。missing / outdated 两张卡共用。 +/// The "one-click install" button plus the terminal fallback. Shared by +/// the missing and the outdated cards. /// -/// 终端方案必须保留而不是彻底删掉:`swift run` 跑的裸可执行文件没有内嵌载荷, -/// 按钮会明确报错引导到脚本;也总有人就是不愿意往 App 弹的框里输密码。 +/// The terminal route has to be kept rather than dropped entirely: the +/// bare executable from `swift run` has no embedded payload, and the +/// button reports that clearly and points at the script; and there are +/// always people who simply will not type their password into a dialog +/// the app pops up. private struct HelperInstallSection: View { var model: AppModel let buttonTitle: String @@ -242,7 +252,7 @@ private struct HelperInstallSection: View { } } -/// 一条可复制的命令。 +/// A command that can be copied. struct CopyableCommand: View { let command: String @State private var copied = false @@ -258,7 +268,9 @@ struct CopyableCommand: View { NSPasteboard.general.clearContents() NSPasteboard.general.setString(command, forType: .string) copied = true - // 2 秒后复原。给的是「已复制」这个确认,不是一个需要用户操作的状态。 + // Restore after 2 seconds. It gives the "copied" + // confirmation, not a state that needs the user to act on + // it. Task { try? await Task.sleep(for: .seconds(2)) copied = false diff --git a/gui/Sources/TetherKitApp/Views/DeviceCard.swift b/gui/Sources/TetherKitApp/Views/DeviceCard.swift index e6dff38..fdc183a 100644 --- a/gui/Sources/TetherKitApp/Views/DeviceCard.swift +++ b/gui/Sources/TetherKitApp/Views/DeviceCard.swift @@ -1,10 +1,11 @@ import SwiftUI import TetherKitIPC -/// 设备选择与会话参数。 +/// Device selection and session parameters. /// -/// 会话跑起来之后整块变成只读 —— 改 MTU 或换设备都需要重连,做成「能改但不生效」 -/// 比直接禁用更让人困惑。 +/// Once the session runs the whole block becomes read-only -- changing +/// the MTU or switching devices requires a reconnect, and offering +/// "editable but ineffective" is more confusing than disabling it. struct DeviceCard: View { @Bindable var model: AppModel @@ -43,7 +44,8 @@ struct DeviceCard: View { ContentUnavailableView { Label(L(.noDeviceDetected), systemImage: "cable.connector.slash") } description: { - // 这三条正是 macOS 上 RNDIS 连不上的全部常见原因,按命中率排序。 + // These three are the common reasons RNDIS fails to connect + // on macOS, ordered by hit rate. VStack(alignment: .leading, spacing: 4) { Text(L(.deviceChecklistCable)) Text(L(.deviceChecklistTethering)) @@ -66,16 +68,20 @@ struct DeviceCard: View { } } - // 长解释一律放进悬停提示:这两个参数一年也调不了一次,说明文字却天天占着 - // 屏幕 —— 界面上只留「什么时候需要动它」这一句。 + // Long explanations go into the tooltips: these two parameters get + // adjusted once a year at most, while the explanatory text would + // occupy the screen every day -- on the interface keep only the one + // sentence about when to touch it. private var tuningControls: some View { VStack(alignment: .leading, spacing: Design.Spacing.small) { HStack { Text("MTU") .font(.callout) .frame(width: 92, alignment: .leading) - // 步进 100 而不是 1:MTU 是要和对端协商的,逐字节微调没有意义, - // 而 1500 / 1400 / 2000 这样的整数才是用户真正会试的值。 + // Step by 100 rather than 1: the MTU is negotiated with + // the peer, byte-by-byte tuning is meaningless, and round + // values like 1500 / 1400 / 2000 are what users actually + // try. Stepper(value: $model.requestedMTU, in: 576...2048, step: 100) { Text(L(.mtuBytes, String(model.requestedMTU))) .font(.system(.callout, design: .monospaced)) @@ -83,8 +89,11 @@ struct DeviceCard: View { .disabled(isLocked) Spacer() } - // 上限只有在环境预检拿到之后才知道,所以是两条独立文案而不是拼接 —— - // 「(本机上限 N)」这半句在英文里落到句子的另一个位置,拼不出来。 + // The cap is only known once the environment preflight has + // run, so there are two separate strings instead of a + // concatenation -- the "(host limit N)" half lands in a + // different place in an English sentence and cannot be + // stitched together. Text(model.environment.map { L(.mtuHelpWithLimit, Int($0.fethMaxMTU)) } ?? L(.mtuHelp)) .font(.caption) @@ -106,7 +115,7 @@ struct DeviceCard: View { } } -/// 设备列表里的一行。 +/// One row in the device list. private struct DeviceRow: View { let device: DeviceDescriptor let isSelected: Bool @@ -137,8 +146,10 @@ private struct DeviceRow: View { Spacer(minLength: 0) if device.usedAndroidQuirk { - // 这条信息对排障很有用:走了兜底路径说明设备的 CDC 描述符不 - // 规范,日后若出问题这是第一个该看的线索。 + // This information is useful when troubleshooting: + // taking the fallback path means the device's CDC + // descriptors are not standard, and if something goes + // wrong later this is the first clue to look at. StatusBadge(text: L(.compatibilityMode), color: .orange) .help(L(.compatibilityModeTooltip)) } diff --git a/gui/Sources/TetherKitApp/Views/LogCard.swift b/gui/Sources/TetherKitApp/Views/LogCard.swift index aaa47ac..d0a7670 100644 --- a/gui/Sources/TetherKitApp/Views/LogCard.swift +++ b/gui/Sources/TetherKitApp/Views/LogCard.swift @@ -142,7 +142,8 @@ private struct LogRow: View { .foregroundStyle(.tertiary) Text(entry.latest.level.label) .foregroundStyle(Design.logColor(for: entry.latest.level)) - // 固定宽度让级别列对齐,扫读时眼睛不用横向找。 + // A fixed width aligns the level column, so the eye does + // not have to search sideways when scanning. .frame(width: 46, alignment: .leading) Text(entry.latest.message) .foregroundStyle(Design.logColor(for: entry.latest.level)) diff --git a/gui/Sources/TetherKitApp/Views/MenuBarPanel.swift b/gui/Sources/TetherKitApp/Views/MenuBarPanel.swift index c3e974d..181eeff 100644 --- a/gui/Sources/TetherKitApp/Views/MenuBarPanel.swift +++ b/gui/Sources/TetherKitApp/Views/MenuBarPanel.swift @@ -2,10 +2,12 @@ import AppKit import SwiftUI import TetherKitIPC -/// 菜单栏里的常驻标签:状态图标 + 实时速率。 +/// The permanent menu bar label: a status icon plus live rates. /// -/// 只在会话运行时显示速率文本 —— 空闲时一串 "0K" 只是噪音,图标本身已经把 -/// 状态说清楚了。数字用等宽,避免速率跳动时把右边的其它菜单栏项挤来挤去。 +/// The rate text appears only while a session is running -- when idle a +/// string of "0K" is just noise, and the icon itself already says what +/// the state is. The digits are monospaced, so a changing rate does not +/// shove the other menu bar items to the right around. struct MenuBarLabel: View { var model: AppModel @@ -25,10 +27,12 @@ struct MenuBarLabel: View { } } -/// 点开菜单栏项后的小面板。 +/// The small panel that opens when the menu bar item is clicked. /// -/// 内容按「瞟一眼要知道什么」取舍:状态、速率、网卡与地址,再加两个动作 -/// (打开主窗口、退出)。配置、图表、日志一律留在主窗口 —— 面板不是第二个界面。 +/// The content is chosen by "what do you need to know at a glance": the +/// state, the rates, the interface and the address, plus two actions +/// (open the main window, quit). Configuration, charts and logs stay in +/// the main window -- the panel is not a second interface. struct MenuBarPanel: View { var model: AppModel @Environment(\.dismiss) private var dismiss @@ -59,10 +63,15 @@ struct MenuBarPanel: View { } .padding(Design.Spacing.medium) .frame(width: 280) - // 与主窗口同理:文案来自全局查表,改语言不会让任何 @Observable 属性 - // 「看起来」变了,得靠显式 identity 强制重建。面板本身会随轮询刷新, - // 不加这行语言也会在下一个周期跟上 —— 但从面板里改语言时,用户盯着 - // 的就是这块面板,慢半拍会被当成没生效。 + // Same reasoning as the main window: the strings come from a + // global table lookup, so changing the language makes no + // @Observable property "look" changed, and an explicit identity is + // needed to force a rebuild. The panel itself refreshes with the + // polling, so without this line the language would catch up on + // the next cycle anyway -- but when the user changes the language + // from inside the panel, the panel is exactly what they are + // staring at, and half a beat of delay reads as "it did not take + // effect". .id(model.languageRevision) } @@ -120,9 +129,13 @@ struct MenuBarPanel: View { Spacer() - // 仅菜单栏模式下这块面板是唯一入口,语言开关必须在这里也够得到 —— - // 否则一个看不懂界面的用户,得先知道「要去 App 菜单」才能改语言, - // 而在这个模式下 App 菜单只有把窗口开出来才看得见。 + // In menu-bar-only mode this panel is the only entry + // point, so the language switch has to be reachable here + // as well -- otherwise a user who cannot read the + // interface has to know that they should "go to the app + // menu" before they can change the language, and in this + // mode the app menu is only visible once the window is + // opened. languageMenu Button(L(.quit)) { @@ -132,8 +145,10 @@ struct MenuBarPanel: View { } if status.runState == .running { - // 会话属于 helper,App 退出不影响它 —— 这一点必须说出来, - // 否则用户会以为「退出 = 断网」而不敢点。 + // The session belongs to the helper and quitting the app + // does not affect it -- this has to be said out loud, + // otherwise users think "quitting = losing the network" + // and hesitate to click. Text(L(.quitTooltip)) .font(.caption2) .foregroundStyle(.tertiary) diff --git a/gui/Sources/TetherKitApp/Views/NetworkCard.swift b/gui/Sources/TetherKitApp/Views/NetworkCard.swift index 55b790b..a0abb42 100644 --- a/gui/Sources/TetherKitApp/Views/NetworkCard.swift +++ b/gui/Sources/TetherKitApp/Views/NetworkCard.swift @@ -3,13 +3,15 @@ import TetherKitCore import TetherKitIPC import TetherKitUIModel -/// 上网方式配置:DHCP 或静态 IP。 +/// Network access mode configuration: DHCP or a static IP. /// -/// ★ 「当前生效」一栏为什么单独存在 ★ -/// 它显示的是**从系统回读**的状态,不是我们下发的值。两者可能不同 —— -/// 最典型的是静态模式下的 DNS:IPConfiguration 只在 DHCP 模式发布 DNS, -/// 静态模式我们只能尽力而为地补一个键,能不能被系统采纳取决于 IPMonitor。 -/// 与其向用户承诺一个可能不成立的结果,不如把真实状态摆出来。 +/// Why the "currently effective" row exists on its own: +/// It shows the state **read back from the system**, not the value we +/// pushed. The two can differ -- the most typical case is DNS in static +/// mode: IPConfiguration only publishes DNS in DHCP mode, and in static +/// mode all we can do is add a key on a best-effort basis; whether the +/// system adopts it depends on IPMonitor. Rather than promise the user +/// a result that may not hold, show the real state. /// /// This card lives on the "Network" page with the full content width /// to itself. The two-column grid works on a full-width page: a single @@ -18,17 +20,21 @@ import TetherKitUIModel struct NetworkCard: View { @Bindable var model: AppModel - /// 网卡还没创建时整块禁用 —— 没有网卡可配,让用户填完再报错是最糟的顺序。 + /// The whole block is disabled while the interface does not exist yet + /// -- with no interface to configure, making the user fill in the form + /// and only then report an error is the worst possible order. private var interfaceReady: Bool { !model.status.systemInterface.isEmpty } private var canAddDNS: Bool { - // 上限与 C ABI 的 TK_DNS_MAX 一致,多填的会被丢弃,不如直接不让加。 + // The cap matches TK_DNS_MAX in the C ABI; extra entries would + // be dropped, so it is better not to allow adding them at all. model.networkConfiguration.dnsServers.count < 4 } - /// 静态 DNS 的悬停说明。它属于「什么时候需要在意」级别的信息, - /// 不值得常驻一行。 - /// 计算属性而非 `static let`:后者只求值一次,切换语言后就不再更新了。 + /// The tooltip for static DNS. It is "when do I need to care" level + /// information and does not deserve a permanent row. + /// A computed property rather than `static let`: the latter is + /// evaluated only once and stops updating after a language switch. private static var dnsHint: String { L(.dnsEffectivenessTooltip) } var body: some View { @@ -138,7 +144,9 @@ struct NetworkCard: View { dnsField(at: 0) } } - // 第 2 条起的 DNS 各占一行的右格,左格空着 —— 和上面的 DNS 对齐。 + // From the 2nd entry on, each DNS row occupies the right + // cell of a row and the left cell stays empty -- so they line + // up with the DNS field above. ForEach(Array(model.networkConfiguration.dnsServers.indices.dropFirst()), id: \.self) { index in GridRow { @@ -149,7 +157,8 @@ struct NetworkCard: View { } } - /// 一条 DNS:输入格 + 删除,最后一条再带一个「添加」。 + /// One DNS entry: the input field plus a delete button, and the last + /// entry also carries an "add" button. private func dnsField(at index: Int) -> some View { HStack(spacing: Design.Spacing.tight) { AddressField(label: index == 0 ? "DNS" : "", @@ -179,7 +188,8 @@ struct NetworkCard: View { .help(Self.dnsHint) } - /// 一条 DNS 都没有时占住格子的「添加」。 + /// The "add" that occupies the cell when there is no DNS entry at + /// all. private var emptyDNSCell: some View { HStack(spacing: Design.Spacing.tight) { Text("DNS") @@ -237,13 +247,17 @@ struct NetworkCard: View { } } -/// 一个带即时校验反馈的紧凑地址输入框:标签在左,校验图标叠在输入框内侧 -/// (两列布局里每一点宽度都金贵)。 +/// A compact address input field with immediate validation feedback: the +/// label on the left, the validation icon overlaid on the inside edge of +/// the field (in a two-column layout every pixel of width is precious). /// -/// 校验放在输入时而不是提交时:地址填错是最常见的操作失误,等点了「应用」再报错 -/// 会让用户来回猜是哪一格错了。 +/// Validation happens while typing rather than on submit: a mistyped +/// address is the most common mistake, and reporting it only after +/// pressing "Apply" makes the user guess back and forth which field is +/// wrong. private struct AddressField: View { - /// 标签列宽。回读区的标签也用它,两块的竖向对齐线才是同一条。 + /// The label column width. The readback area uses it too, so the + /// vertical alignment line of the two blocks is the same one. static let labelWidth: CGFloat = 56 let label: String @@ -262,7 +276,8 @@ private struct AddressField: View { .textFieldStyle(.roundedBorder) .font(.system(.callout, design: .monospaced)) .overlay(alignment: .trailing) { - // 空输入不标红:还没填完不算错。 + // An empty input is not marked red: an unfinished + // entry is not a mistake yet. if !text.isEmpty { Image(systemName: isValid ? "checkmark.circle.fill" : "exclamationmark.circle.fill") @@ -276,7 +291,7 @@ private struct AddressField: View { } } -/// 从系统回读的真实生效状态。 +/// The real effective state, read back from the system. private struct EffectiveStateView: View { let state: NetworkState let interface: String @@ -299,8 +314,11 @@ private struct EffectiveStateView: View { } if state.hasAddress { - // 两列四格而不是四行:这是纯展示区,紧凑优先。放不下的值 - // (多条 DNS)中截显示,悬停能看全,也能选中复制。 + // Two columns of four cells rather than four rows: this + // is a pure display area, compactness first. Values that + // do not fit (multiple DNS entries) are shown truncated, + // and the tooltip shows the whole thing and allows + // selecting and copying it. Grid(alignment: .leading, horizontalSpacing: Design.Spacing.medium, verticalSpacing: Design.Spacing.tight) { diff --git a/gui/Sources/TetherKitApp/Views/StatusHeroCard.swift b/gui/Sources/TetherKitApp/Views/StatusHeroCard.swift index 97416e6..3affe69 100644 --- a/gui/Sources/TetherKitApp/Views/StatusHeroCard.swift +++ b/gui/Sources/TetherKitApp/Views/StatusHeroCard.swift @@ -1,10 +1,13 @@ import SwiftUI import TetherKitIPC -/// 主状态卡:整个界面的视觉与操作重心。 +/// The main status card: the visual and operational center of the whole +/// interface. /// -/// 一屏之内回答四个问题:现在通不通、通到哪张网卡、什么 IP、要不要断开。 -/// 其余细节都往下面的卡片里放 —— 用户九成的时间只看这一块。 +/// It answers four questions on one screen: is it connected, through +/// which interface, with which IP, and do I want to disconnect. All the +/// remaining detail goes into the cards below -- nine times out of ten +/// the user only looks at this one. struct StatusHeroCard: View { var model: AppModel @@ -137,16 +140,21 @@ struct StatusHeroCard: View { .buttonStyle(.borderedProminent) .controlSize(.large) .tint(isRunning ? .red : .accentColor) - // 没有设备时不让点「连接」—— 点了必然失败,不如直接说明白。 + // Do not allow clicking "Connect" when there is no device -- the + // click is guaranteed to fail, so it is better to say so + // plainly. .disabled(model.isBusy || isTransitional || (!isRunning && model.devices.isEmpty)) .help(model.devices.isEmpty && !isRunning ? L(.connectDisabledHint) : "") } } -/// 状态圆环。 +/// The status ring. /// -/// 运行中时外圈缓慢旋转、内圈呼吸 —— 这是界面上唯一的动效,用来表达「它在活着 -/// 干活」。其余状态一律静止,避免把注意力浪费在没有信息量的动画上。 +/// While running, the outer ring turns slowly and the inner ring +/// breathes -- this is the only animation in the interface, and it +/// expresses "it is alive and working". Every other state stays +/// motionless, to avoid spending attention on animation that carries no +/// information. private struct StatusRing: View { let status: SessionStatus let accent: Color @@ -168,7 +176,8 @@ private struct StatusRing: View { .stroke(accent.opacity(0.25), lineWidth: 3) .frame(width: 64, height: 64) - // 过渡态用一段旋转的弧表示「正在进行中,进度不可知」。 + // The transitional state uses a rotating arc to say "in + // progress, progress unknowable". Circle() .trim(from: 0, to: isWorking ? 0.25 : (isActive ? 1.0 : 0.0)) .stroke(accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) diff --git a/gui/Sources/TetherKitApp/Views/ThroughputCard.swift b/gui/Sources/TetherKitApp/Views/ThroughputCard.swift index 6252e5d..a18e9b3 100644 --- a/gui/Sources/TetherKitApp/Views/ThroughputCard.swift +++ b/gui/Sources/TetherKitApp/Views/ThroughputCard.swift @@ -2,10 +2,13 @@ import Charts import SwiftUI import TetherKitIPC -/// 吞吐与计数器。 +/// Throughput and counters. /// -/// 图表刻意只画**速率**而不画累计量:累计曲线永远单调上升,看不出任何东西; -/// 而速率能一眼看出卡顿、抖动和被限速。累计量放在下面的指标格里,需要时能读到。 +/// The chart deliberately plots the **rate** and not the cumulative +/// total: a cumulative curve only ever climbs and shows nothing; the +/// rate makes stutter, jitter and throttling visible at a glance. The +/// cumulative totals sit in the metric tiles below, readable when +/// needed. /// /// The card carries no title row: it is the body of the "Throughput" /// page and the page header already says it (with the LIVE badge). @@ -90,8 +93,10 @@ struct ThroughputCard: View { } } } - // 横轴不画刻度:这是一条「最近 60 秒」的滚动曲线,具体时刻没有意义, - // 画上去只会挤占本来就不高的绘图区。 + // The x axis carries no ticks: this is a rolling curve over + // "the last 60 seconds", the exact moments mean nothing, and + // drawing them would only eat into an already short plot + // area. .chartXAxis(.hidden) .frame(height: 120) } else { @@ -119,11 +124,14 @@ struct ThroughputCard: View { || model.status.linkKernelDrops > 0 || model.status.txBackpressure > 0 } - /// 丢包提示。 + /// The drop hint. /// - /// 分开列四个数字而不是合成一个「丢包率」:它们指向完全不同的瓶颈 —— - /// 队列满是下游写不过来,内核丢是我们读得不够快,背压是 USB 侧发不出去。 - /// 合并之后就分不清该调哪个参数了。 + /// Four numbers are listed separately instead of being merged into one + /// "drop rate": they point at completely different bottlenecks -- a + /// full queue means the downstream side cannot keep up, kernel drops + /// mean we are not reading fast enough, and backpressure means the + /// USB side cannot send. Once they are merged, there is no way to + /// tell which parameter to turn. private var dropHint: some View { HStack(spacing: Design.Spacing.medium) { MetricTile(caption: L(.downstreamDropped), value: Format.count(model.status.rxDropped), tint: .orange) diff --git a/gui/Sources/TetherKitCore/CInterop.swift b/gui/Sources/TetherKitCore/CInterop.swift index 9024c26..e5c7b0c 100644 --- a/gui/Sources/TetherKitCore/CInterop.swift +++ b/gui/Sources/TetherKitCore/CInterop.swift @@ -1,15 +1,18 @@ import CTetherKit import Foundation -// C 的定长 char 数组在 Swift 里被导入成**元组**(`char[16]` → 16 个 CChar 的 -// 元组),既不能下标访问也不能直接和 String 互转。这里集中处理这件事, -// 避免每个字段都写一遍不安全的指针操作。 +// C fixed-size char arrays are imported into Swift as tuples (`char[16]` -> a +// tuple of 16 CChars), which cannot be subscripted or converted to/from String +// directly. This is handled centrally here to avoid repeating unsafe pointer +// operations for every field. extension String { - /// 从 C 的定长 char 数组(导入为元组)读出字符串。 + /// Read a string from a C fixed-size char array (imported as a tuple). /// - /// 不用 `String(cString:)`:那要求缓冲一定有 NUL 结尾,一旦 C 侧写满了整个 - /// 缓冲就会越界读。这里改成「扫到 NUL 或扫到底」,两种情况都安全。 + /// Does not use `String(cString:)`: that requires the buffer to be + /// NUL-terminated, which would read past the end if the C side filled the + /// entire buffer. This scans until NUL or the end, which is safe in both + /// cases. init(fixedCArray tuple: T) { var mutableCopy = tuple self = withUnsafeBytes(of: &mutableCopy) { raw in @@ -19,26 +22,29 @@ extension String { } } -/// 把字符串写进 C 的定长 char 数组,保证 NUL 结尾。 +/// Write a string into a C fixed-size char array, guaranteeing NUL termination. /// -/// 超长时截断,且**截断落在 UTF-8 字符边界上** —— 按字节硬切会在缓冲末尾留下 -/// 半个字符,C 侧再读出来就是一串替换字符。(C 侧的 CopyText 有同样的处理, -/// 两个方向都得管。) +/// Truncates when too long, and truncation falls on a UTF-8 character boundary +/// -- cutting on a raw byte would leave half a character at the end of the +/// buffer, which would read back on the C side as replacement characters. (C's +/// CopyText handles this the same way; both directions must take care of it.) func setFixedCArray(_ tuple: inout T, to string: String) { withUnsafeMutableBytes(of: &tuple) { raw in writeCString(string, into: raw) } } -/// 把字符串写进一段原始缓冲,NUL 结尾 + UTF-8 边界安全截断。 +/// Write a string into a raw buffer with NUL termination and UTF-8-boundary-safe +/// truncation. private func writeCString(_ string: String, into raw: UnsafeMutableRawBufferPointer) { guard raw.count > 0 else { return } for index in raw.indices { raw[index] = 0 } let utf8 = Array(string.utf8) var length = min(utf8.count, raw.count - 1) - // length < utf8.count 说明截断了;若切点落在多字节序列中间(续接字节的高两位 - // 是 0b10),一路退到该序列的起点。 + // length < utf8.count means truncation occurred; if the cut falls inside a + // multibyte sequence (continuation bytes have the top two bits 0b10), walk + // back to the start of that sequence. while length > 0, length < utf8.count, utf8[length] & 0xC0 == 0x80 { length -= 1 } @@ -47,9 +53,11 @@ private func writeCString(_ string: String, into raw: UnsafeMutableRawBufferPoin } } -/// 把字符串写进 C 的二维定长数组的第 row 行(如 `char dns[4][46]`)。 +/// Write a string into row `row` of a C 2D fixed-size array (for example +/// `char dns[4][46]`). /// -/// 二维数组在 Swift 里是「元组的元组」,没法用下标,只能按字节偏移定位。 +/// A 2D array is imported into Swift as a "tuple of tuples" and cannot be +/// subscripted, so it must be located by byte offset. func setFixedCArrayRow(_ tuple: inout T, row: Int, stride: Int, to string: String) { withUnsafeMutableBytes(of: &tuple) { raw in let start = row * stride @@ -58,7 +66,7 @@ func setFixedCArrayRow(_ tuple: inout T, row: Int, stride: Int, to string: St } } -/// 读 C 的二维定长数组的第 row 行。 +/// Read row `row` of a C 2D fixed-size array. func fixedCArrayRow(_ tuple: T, row: Int, stride: Int) -> String { var mutableCopy = tuple return withUnsafeBytes(of: &mutableCopy) { raw in @@ -69,7 +77,8 @@ func fixedCArrayRow(_ tuple: T, row: Int, stride: Int) -> String { } } -/// 把 6 字节 MAC 元组渲染成 "aa:bb:cc:dd:ee:ff";全 0 时返回空串。 +/// Render a 6-byte MAC tuple as "aa:bb:cc:dd:ee:ff"; returns an empty string +/// when all zeros. func formatMAC(_ tuple: T) -> String { var mutableCopy = tuple return withUnsafeBytes(of: &mutableCopy) { raw in diff --git a/gui/Sources/TetherKitCore/NetworkConfigurator.swift b/gui/Sources/TetherKitCore/NetworkConfigurator.swift index 3ee5b86..0c2965c 100644 --- a/gui/Sources/TetherKitCore/NetworkConfigurator.swift +++ b/gui/Sources/TetherKitCore/NetworkConfigurator.swift @@ -2,19 +2,21 @@ import CTetherKit import Foundation import TetherKitIPC -/// 虚拟网卡的上网方式配置。 +/// Network access configuration for a virtual interface. /// -/// `apply` / `clear` 需要 root(只在 helper 里调);`query` 不需要。 +/// `apply` / `clear` require root (called only from the helper); `query` does not. public enum NetworkConfigurator { - /// tk_ip_config_t 里每个地址字段的容量,用于定位二维数组 `dns[4][46]` 的行。 + /// Capacity of each address field in tk_ip_config_t, used to locate rows in + /// the 2D array `dns[4][46]`. private static let addressStride = Int(TK_ADDRESS_CAPACITY) private static let maxDNSServers = Int(TK_DNS_MAX) - /// 下发配置。 + /// Apply configuration. /// - /// ⚠️ DHCP 模式下会**阻塞**到拿到租约或超时(库内部上限 10 秒)。这是刻意的: - /// 「已下发,你自己去轮询」对界面来说没法给出有意义的成败反馈。调用方必须 - /// 保证不在主线程上调它。 + /// In DHCP mode this blocks until a lease is obtained or it times out + /// (library-internal limit 10 seconds). This is deliberate: "applied, poll it + /// yourself" cannot give the UI meaningful success/failure feedback. The + /// caller must ensure this is not called on the main thread. public static func apply(_ configuration: NetworkConfiguration, to interface: String) throws { var raw = tk_ip_config_t() tk_ip_config_init(&raw) @@ -41,17 +43,18 @@ public enum NetworkConfigurator { try check(result, error) } - /// 撤销网卡上的 IP 配置。 + /// Revoke the IP configuration on an interface. public static func clear(interface: String) throws { var error = tk_error_t() let result = interface.withCString { tk_net_clear($0, &error) } try check(result, error) } - /// 回读网卡**真实生效**的状态。 + /// Read back the interface's effective state. /// - /// 网卡还不存在时返回全空的状态而不是抛错 —— 界面在会话没起来时也会刷新, - /// 那时挂一个假的错误只会误导人。 + /// Returns an all-empty state instead of throwing when the interface does not + /// yet exist -- the UI also refreshes before a session is up, and surfacing a + /// spurious error there would only mislead. public static func query(interface: String) throws -> NetworkState { var raw = tk_net_state_t() var error = tk_error_t() @@ -77,34 +80,38 @@ public enum NetworkConfigurator { } } -// MARK: - 校验 +// MARK: - Validation -/// 静态 IP 表单的输入校验。 +/// Input validation for the manual IP form. /// -/// 放在共享层而不是界面里:helper 侧也要挡一道(XPC 是任何本机进程都能连的), -/// 两边用同一份规则才不会出现「界面通过了但 helper 拒绝」的割裂。 +/// Kept in the shared layer rather than the UI: the helper side must also reject +/// bad input (XPC can be reached by any local process), and both sides must use +/// the same rules to avoid "UI accepts but helper rejects" splits. public enum NetworkValidator { - /// 是否是合法的点分十进制 IPv4 地址。 + /// Whether the text is a valid dotted-decimal IPv4 address. public static func isValidIPv4(_ text: String) -> Bool { var address = in_addr() return text.withCString { inet_pton(AF_INET, $0, &address) } == 1 } - /// 是否是合法的子网掩码。 + /// Whether the text is a valid subnet mask. /// - /// 比「是合法 IPv4」更严:掩码的二进制必须是连续的 1 后面跟连续的 0。 - /// 255.255.0.255 这种能通过 inet_pton 但作为掩码是错的,而内核对它的反应 - /// 是给出一个诡异的路由,非常难查 —— 所以在入口就挡掉。 + /// Stricter than "is a valid IPv4 address": a mask's binary form must be + /// contiguous 1s followed by contiguous 0s. Values like 255.255.0.255 pass + /// inet_pton but are wrong as masks, and the kernel reacts to them with a + /// bizarre route that is very hard to diagnose -- so reject them at entry. public static func isValidNetmask(_ text: String) -> Bool { var address = in_addr() guard text.withCString({ inet_pton(AF_INET, $0, &address) }) == 1 else { return false } let host = UInt32(bigEndian: address.s_addr) - // 连续掩码的充要条件:取反加一后是 2 的幂(或为 0,对应 255.255.255.255)。 + // A contiguous mask is necessary and sufficient: bitwise-not plus one is + // a power of two (or zero, corresponding to 255.255.255.255). let inverted = ~host return inverted & (inverted &+ 1) == 0 } - /// 逐项校验一份静态 IP 配置,返回第一条不通过的说明;全通过时返回 nil。 + /// Validate a manual IP configuration item by item and return the first + /// failure message; returns nil when everything passes. public static func validationMessage(for configuration: NetworkConfiguration) -> String? { guard configuration.mode == .manual else { return nil } diff --git a/gui/Sources/TetherKitCore/TetherKitError.swift b/gui/Sources/TetherKitCore/TetherKitError.swift index 4d462b1..d2f5fd7 100644 --- a/gui/Sources/TetherKitCore/TetherKitError.swift +++ b/gui/Sources/TetherKitCore/TetherKitError.swift @@ -2,10 +2,10 @@ import CTetherKit import TetherKitIPC import Foundation -/// 来自 C ABI 的错误。 +/// Error from the C ABI. /// -/// `message` 已经是可以直接展示给用户的中文(库那边就是按这个标准写的), -/// 界面不需要再翻译一次。 +/// `message` is already text that can be shown directly to the user (the library +/// writes it to that standard), so the UI does not need to translate it again. public struct TetherKitError: LocalizedError, Sendable { public enum Domain: Int32, Sendable { case generic = 0 @@ -21,7 +21,8 @@ public struct TetherKitError: LocalizedError, Sendable { public var errorDescription: String? { message } - /// 是否是「缺少权限」—— 界面据此决定弹授权还是报错。 + /// Whether this is "permission denied" -- the UI uses this to decide between + /// requesting authorization and reporting an error. public var isPermissionDenied: Bool { result == TK_ERR_PERMISSION.rawValue } init(result: Int32, error: tk_error_t) { @@ -29,8 +30,10 @@ public struct TetherKitError: LocalizedError, Sendable { self.domain = Domain(rawValue: error.domain) ?? .generic self.code = error.code let text = String(fixedCArray: error.message) - // C 侧在参数校验失败的分支上未必填了消息(比如纯空指针检查), - // 这时给一句兜底的,总比界面上出现一个空的错误框强。 + // The C side does not always fill in a message on argument-validation + // failure paths (for example, a plain null-pointer check), so provide a + // backstop message here -- better than showing an empty error box in the + // UI. self.message = text.isEmpty ? L(.libraryGenericFailure, Int(result)) : text } @@ -42,7 +45,7 @@ public struct TetherKitError: LocalizedError, Sendable { } } -/// 把 C 的返回码 + 错误结构体翻译成 Swift 的抛出。 +/// Translate a C result code + error struct into a Swift throw. func check(_ result: tk_result_t, _ error: tk_error_t) throws { guard result != TK_OK else { return } throw TetherKitError(result: result.rawValue, error: error) diff --git a/gui/Sources/TetherKitCore/TetherKitLibrary.swift b/gui/Sources/TetherKitCore/TetherKitLibrary.swift index 9d6d0cf..396d70e 100644 --- a/gui/Sources/TetherKitCore/TetherKitLibrary.swift +++ b/gui/Sources/TetherKitCore/TetherKitLibrary.swift @@ -2,27 +2,32 @@ import CTetherKit import Foundation import TetherKitIPC -/// libtetherkit 的进程级接口:版本、环境预检、设备枚举、日志。 +/// Process-wide interface to libtetherkit: version, environment preflight, +/// device enumeration, and logging. /// -/// 这一组**不需要 root**,App 与 helper 都能调。 +/// This group does not require root; both the App and the helper can call it. public enum TetherKitLibrary { - /// 一次最多枚举多少台设备。 + /// Maximum number of devices enumerated in one call. /// - /// 16 是个不会被碰到的上限:一台机器同时插 16 台 RNDIS 设备已经离谱了, - /// 而定长数组换来的是「不用管内存」。真超了 tk_list_devices 会如实汇报总数, - /// 我们只是不展示多出来的部分。 + /// 16 is a limit that will never be hit: 16 RNDIS devices plugged into one + /// machine at the same time is already absurd, and a fixed array buys "no + /// memory management". If it really is exceeded, tk_list_devices reports the + /// true total; we simply do not show the extra entries. private static let deviceCapacity = 16 - /// 一次最多取走多少条日志。 + /// Maximum number of log entries drained in one call. /// - /// 与 C 侧环形缓冲的容量(256)一致:小了会永远取不完积压,大了没意义。 + /// Matches the C-side ring buffer capacity (256): smaller would never fully + /// drain a backlog; larger is meaningless. private static let logCapacity = 256 - /// 把界面语言同步给 libtetherkit。 + /// Sync the UI language to libtetherkit. /// - /// 必须调 —— 否则会出现「界面是英文、日志卡里的库日志是中文」这种割裂。 - /// 已经排队的日志不会被重新渲染(它们在产生的那一刻就已经是字符串了), - /// 所以越早调越好;语言切换时新产生的日志立刻就是新语言。 + /// Must be called -- otherwise the UI would be in one language while library + /// logs in the log card would be in another. Logs already queued are not + /// re-rendered (they became strings at the moment they were produced), so the + /// sooner this is called the better; after a language switch, newly produced + /// logs are immediately in the new language. public static func setLanguage(_ language: Language) { tk_set_language(language.cValue) } @@ -35,7 +40,8 @@ public enum TetherKitLibrary { String(fixedCArray: info.libusb)) } - /// 运行环境预检。永远成功 —— 「环境不合格」是结果,不是失败。 + /// Runtime environment preflight. Always succeeds -- "environment not + /// acceptable" is a result, not a failure. public static func checkEnvironment() -> EnvironmentReport { var raw = tk_environment_t() _ = tk_check_environment(&raw) @@ -50,10 +56,11 @@ public enum TetherKitLibrary { libusbVersion: version.libusb) } - /// 枚举 RNDIS 设备。 + /// Enumerate RNDIS devices. /// - /// - Parameter readStrings: 是否读取厂商名 / 产品名 / 序列号。这需要打开 - /// 设备,会话运行期间设备已被独占,那时应传 false 免得白试一遍。 + /// - Parameter readStrings: Whether to read manufacturer / product / serial. + /// This requires opening the device; while a session is running the device + /// is held exclusively, so pass false then to avoid a pointless attempt. public static func listDevices(readStrings: Bool = true) throws -> [DeviceDescriptor] { var buffer = [tk_device_info_t](repeating: tk_device_info_t(), count: deviceCapacity) var count = 0 @@ -76,9 +83,9 @@ public enum TetherKitLibrary { } } - // MARK: - 日志 + // MARK: - Logs - /// 打开日志捕获并设定级别。 + /// Enable log capture and set the level. public static func startLogCapture(level: LogLevel = .info) { tk_set_log_level(level.rawValue) tk_enable_log_capture(true) @@ -88,7 +95,8 @@ public enum TetherKitLibrary { tk_enable_log_capture(false) } - /// 取走已缓冲的日志。返回条目与「因缓冲写满被丢弃的条数」。 + /// Drain buffered logs. Returns the entries and the number dropped because + /// the buffer was full. public static func drainLogs() -> (entries: [LogEntry], dropped: UInt64) { var buffer = [tk_log_record_t](repeating: tk_log_record_t(), count: logCapacity) var dropped: UInt64 = 0 @@ -104,12 +112,14 @@ public enum TetherKitLibrary { return (Array(entries), dropped) } - // MARK: - 孤儿网卡 + // MARK: - Orphan interfaces - /// 清理进程被强杀后残留在内核里的 feth 接口。需要 root。 + /// Clean up feth interfaces left in the kernel after the process was killed. + /// Requires root. /// - /// helper 应在启动时无条件调一次:上一次运行若被 SIGKILL,网卡还留在内核里, - /// 而 RAII 与信号处理都救不了那种情况。 + /// The helper should call this unconditionally on startup: if the previous + /// run was killed with SIGKILL, interfaces remain in the kernel, and neither + /// RAII nor signal handlers can recover from that case. @discardableResult public static func cleanupOrphanInterfaces() throws -> Int { var removed = 0 diff --git a/gui/Sources/TetherKitCore/TetherKitSession.swift b/gui/Sources/TetherKitCore/TetherKitSession.swift index 797f9e5..04b880c 100644 --- a/gui/Sources/TetherKitCore/TetherKitSession.swift +++ b/gui/Sources/TetherKitCore/TetherKitSession.swift @@ -2,20 +2,24 @@ import CTetherKit import Foundation import TetherKitIPC -/// 一个 RNDIS 会话。**需要 root**,因此只在 helper 进程里使用。 +/// An RNDIS session. Requires root, so it is used only inside the helper +/// process. /// -/// 线程安全:C 侧的会话本身是线程安全的(状态快照与事件队列都有锁),但 -/// `start` / `stop` 的调用顺序需要调用方自己保证不并发。helper 用一个串行 -/// 队列串起所有生命周期调用来满足这一点。 +/// Thread safety: the C-side session is itself thread-safe (status snapshots and +/// the event queue are both locked), but the caller must ensure `start` / `stop` +/// are not called concurrently. The helper satisfies this by serializing all +/// lifecycle calls on a single serial queue. public final class TetherKitSession { private let handle: OpaquePointer - /// 一次最多取走多少条事件,与 C 侧环形缓冲容量(128)一致。 + /// Maximum number of events drained in one call, matching the C-side ring + /// buffer capacity (128). private static let eventCapacity = 128 public init(configuration: SessionConfiguration) throws { var raw = tk_session_config_t() - // 必须先 init 再改:结构体里有一堆调优旋钮,全零初始化会得到无效配置。 + // Must init before mutating: the struct carries many tuning knobs, and + // zero-initialization would produce an invalid configuration. tk_session_config_init(&raw) raw.vendor_id = configuration.vendorID raw.product_id = configuration.productID @@ -32,23 +36,25 @@ public final class TetherKitSession { } deinit { - // tk_session_destroy 内部会先停机(幂等),所以这里不用先 stop。 + // tk_session_destroy stops internally first (idempotent), so there is no + // need to call stop here. tk_session_destroy(handle) } - /// 启动。**非阻塞** —— 返回只表示请求已受理,成败要看后续的 status。 + /// Start. Non-blocking -- returning only means the request was accepted; + /// success or failure is reflected in subsequent status. public func start() throws { var error = tk_error_t() let result = tk_session_start(handle, &error) try check(result, error) } - /// 停机并等待全部拆除完成。幂等。 + /// Stop and wait for all teardown to complete. Idempotent. public func stop() { _ = tk_session_stop(handle) } - /// 取一份状态快照。任意线程可调。 + /// Take a status snapshot. Callable from any thread. public func status() -> SessionStatus { var raw = tk_session_status_t() guard tk_session_status_get(handle, &raw) == TK_OK else { @@ -79,17 +85,20 @@ public final class TetherKitSession { fatalMessage: String(fixedCArray: raw.fatal)) } - /// 取走已排队的事件,渲染成给用户看的一句话。 + /// Drain queued events and render them as one-line notices for the user. /// - /// 只保留「用户会关心」的那几类:链路变化、设备复位、致命错误。RNDIS 内部 - /// 的状态迁移与生命周期迁移界面已经通过 status 展示了,再刷成通知只是噪音。 + /// Keeps only the categories the user cares about: link changes, device + /// resets, and fatal errors. RNDIS-internal state transitions and lifecycle + /// transitions are already shown through status in the UI; surfacing them as + /// notices again would just be noise. public func drainNotices() -> [String] { var buffer = [tk_event_t](repeating: tk_event_t(), count: Self.eventCapacity) let taken = tk_session_poll_events(handle, &buffer, Self.eventCapacity) return buffer.prefix(taken).compactMap { event -> String? in - // C 侧的 tk_event_kind 全是非负值,被 Swift 导入成 UInt32,而结构体 - // 字段是 int32_t —— 两者不能直接比较,必须显式转一次。 + // On the C side tk_event_kind values are all non-negative and are + // imported into Swift as UInt32, while the struct field is int32_t + // -- they cannot be compared directly, so an explicit cast is needed. switch event.kind { case Int32(TK_EVENT_LINK.rawValue): return L(event.a == 1 ? .eventLinkUp : .eventLinkDown) diff --git a/gui/Sources/TetherKitHelper/HelperService.swift b/gui/Sources/TetherKitHelper/HelperService.swift index 5ebac87..b033bc6 100644 --- a/gui/Sources/TetherKitHelper/HelperService.swift +++ b/gui/Sources/TetherKitHelper/HelperService.swift @@ -2,36 +2,43 @@ import Foundation import TetherKitCore import TetherKitIPC -/// helper 的 XPC 服务实现。 +/// XPC service implementation for the helper. /// -/// ★ 线程模型 ★ +/// Threading model: /// -/// XPC 的方法在连接自己的队列上被调用。凡是可能耗时的操作(启动会话要做 USB -/// 握手、DHCP 要等租约)都**立刻派发到自己的串行队列并异步回复**,绝不在 -/// XPC 队列上阻塞 —— 否则同一条连接上后续的状态轮询会被一起卡住,界面表现 -/// 成「整个卡死」。 +/// XPC methods are called on the connection's own queue. Any operation that may +/// take time (starting a session does a USB handshake, DHCP waits for a lease) +/// is dispatched immediately to its own serial queue and replied to +/// asynchronously, never blocking on the XPC queue -- otherwise subsequent +/// status polling on the same connection would be stuck with it, and the UI +/// would appear completely frozen. /// -/// 会话生命周期与网卡配置各用一条串行队列:两者互不阻塞,但各自内部严格串行 -/// (并发的 start/stop 或并发的 ipconfig 都是灾难)。状态查询不排队,直接打到 -/// C 层 —— 那边的快照本来就是线程安全的。 +/// Session lifecycle and network configuration each use their own serial queue: +/// the two do not block each other, but each is strictly serialized internally +/// (concurrent start/stop or concurrent ipconfig calls would be disastrous). +/// Status queries are not queued and go straight to the C layer -- snapshots +/// there are already thread-safe. final class HelperService: NSObject, TetherKitHelperProtocol { private let lifecycleQueue = DispatchQueue(label: "com.tetherkit.helper.lifecycle") private let networkQueue = DispatchQueue(label: "com.tetherkit.helper.network") - /// 保护 session 与 notices。持有时间都极短,用普通锁足够。 + /// Protects session and notices. Hold times are very short, so a plain lock + /// is sufficient. private let stateLock = NSLock() private var session: TetherKitSession? - /// startSession 正在构造 / 握手中。这段窗口里 session 还没登记,但设备 - /// **已经**被本进程打开了 —— 枚举的占用判定必须把它算进去,否则字符串 - /// 读取会往握手中的控制端点再插一笔传输。 + /// startSession is constructing / handshaking. In this window the session is + /// not yet registered, but the device has already been opened by this process + /// -- enumeration's "in use" check must count it, otherwise string reads would + /// insert another transfer onto the control endpoint mid-handshake. private var sessionStarting = false - /// 尚未被 App 取走的提示。 + /// Notices not yet taken by the App. private var pendingNotices: [String] = [] - // MARK: - 不需要授权的探测接口 + // MARK: - Probe interfaces that do not require authorization func helperVersion(reply: @escaping (String) -> Void) { - // 带上 XPC 接口修订号,让 App 能发现「helper 是升级前的旧版本」。 + // Include the XPC interface revision so the App can detect "the helper is + // an old pre-upgrade version". reply(HelperConstants.encodeVersion(TetherKitLibrary.versionInfo.version)) } @@ -40,11 +47,15 @@ final class HelperService: NSObject, TetherKitHelperProtocol { } func listDevices(reply: @escaping (Data?, String?) -> Void) { - // 设备被本进程持有期间不读字符串描述符:读要 libusb_open,白白失败一遍, - // 还会往正在握手的控制端点插传输。判定用「真的持有」而不是「session - // 非 nil」—— failed / stopped 的死会话早就把 USB 拆干净了,把它们也算 - // 「占用」会让设备拔掉重插后一直读不到名字。 - // 跳过不会丢名字:C 层会回填上次成功读到的值(见 environment.cc)。 + // Do not read string descriptors while the device is held by this process: + // reading requires libusb_open, which would fail for nothing and would + // insert another transfer onto the control endpoint mid-handshake. The + // check uses "actually held" rather than "session != nil" -- dead sessions + // in failed/stopped state have already torn down USB, and counting them as + // "held" would prevent names from being read after the device is unplugged + // and plugged back in. + // Skipping does not lose names: the C layer backfills the last + // successfully read values (see environment.cc). let deviceHeld = stateLock.withLock { () -> Bool in if sessionStarting { return true } guard let session else { return false } @@ -63,8 +74,8 @@ final class HelperService: NSObject, TetherKitHelperProtocol { func sessionStatus(reply: @escaping (Data?, String?) -> Void) { let status = withState { $0?.status() } ?? .idle - // 顺带把会话事件收进提示队列 —— 状态轮询本来就是每个刷新周期一次, - // 搭车过来不用多一次 XPC 往返。 + // Piggyback session events into the notice queue -- status polling already + // happens once per refresh cycle, so this avoids an extra XPC round trip. if let notices = withState({ $0?.drainNotices() }), !notices.isEmpty { appendNotices(notices) } @@ -99,7 +110,7 @@ final class HelperService: NSObject, TetherKitHelperProtocol { reply() } - // MARK: - 需要授权的特权接口 + // MARK: - Privileged interfaces that require authorization func startSession(authorization: Data, configuration: Data, reply: @escaping (String?, Bool) -> Void) { @@ -111,24 +122,29 @@ final class HelperService: NSObject, TetherKitHelperProtocol { lifecycleQueue.async { [weak self] in guard let self else { return } - // 已经彻底死掉(失败 / 已停)的旧会话不能挡住新的连接。 + // An old session that is completely dead (failed / stopped) must not + // block a new connection. // - // 典型场景:设备被拔掉 → 保活连续失败 → 会话进入 failed。C++ 侧 - // 此时已经把 USB、网卡全部拆干净了,Swift 这层只剩一个壳 —— 但它 - // 非 nil。若只按「session != nil 就拒绝」,用户重插设备后永远 - // 连不上,只能重装 helper。这个坑真实踩过。 + // Typical scenario: the device is unplugged -> keep-alive fails + // repeatedly -> the session enters failed. At that point the C++ side + // has already torn down USB and the interfaces completely; the Swift + // layer only has an empty shell left -- but it is non-nil. If the + // rule were simply "reject when session != nil", the user could never + // connect again after replugging the device without reinstalling the + // helper. This was a real pitfall. if let existing = self.withState({ $0 }) { let state = existing.status().runState guard state == .failed || state == .stopped else { reply(L(.helperSessionAlreadyRunning), false) return } - existing.stop() // 幂等,只是保险 + existing.stop() // idempotent, just in case self.stateLock.withLock { self.session = nil } } - // 从构造到 start() 返回的整个握手期间把「设备已被持有」亮出来, - // 让并发到来的 listDevices 跳过字符串读取(见那边的说明)。 + // Mark "device is held" for the entire handshake window from + // construction through start() returning, so concurrent listDevices + // calls skip string reads (see the note there). self.stateLock.withLock { self.sessionStarting = true } defer { self.stateLock.withLock { self.sessionStarting = false } } do { @@ -147,8 +163,9 @@ final class HelperService: NSObject, TetherKitHelperProtocol { lifecycleQueue.async { [weak self] in guard let self else { return } - // 先把 session 从状态里摘出来再停:停机要 join 控制线程、可能耗时 - // 数百毫秒,期间不该继续对外声称「会话在运行」。 + // Remove the session from state before stopping it: stopping joins + // the control thread and may take hundreds of milliseconds, during + // which we must not continue to report "session is running". let session = self.stateLock.withLock { () -> TetherKitSession? in defer { self.session = nil } return self.session @@ -165,14 +182,16 @@ final class HelperService: NSObject, TetherKitHelperProtocol { authorize(authorization, reply: reply) else { return } - // 界面已经校验过一遍,但 XPC 是任何本机进程都能连的,helper 必须自己再挡 - // 一道 —— 而且用的是同一份规则,不会出现两边判断不一致。 + // The UI has already validated once, but XPC can be reached by any local + // process, so the helper must reject bad input itself -- and using the + // same rules, so the two sides cannot disagree. if let message = NetworkValidator.validationMessage(for: configuration) { reply(message, false) return } - // DHCP 会阻塞到拿到租约(库内部上限 10 秒),所以必须异步回复。 + // DHCP blocks until a lease is obtained (library-internal limit 10 + // seconds), so the reply must be sent asynchronously. networkQueue.async { [weak self] in do { try NetworkConfigurator.apply(configuration, to: interface) @@ -184,13 +203,14 @@ final class HelperService: NSObject, TetherKitHelperProtocol { } } - // MARK: - 停机清理 + // MARK: - Shutdown cleanup - /// 收到 SIGTERM(`launchctl bootout`)时调用。 + /// Called when SIGTERM is received (`launchctl bootout`). /// - /// Swift 的 deinit 在进程被终止时不会跑,不主动停一下就会把 feth 网卡漏在 - /// 内核里。落盘登记能兜住 SIGKILL,但能优雅退出时还是该优雅退出 —— - /// 那样连「下次启动清理」这一步都省了。 + /// Swift's deinit does not run when a process is terminated; not stopping + /// actively would leak feth interfaces in the kernel. The on-disk registration + /// catches SIGKILL, but when a graceful exit is possible it should be taken + /// -- that way even the "clean up on next launch" step is unnecessary. func shutdown() { let session = stateLock.withLock { () -> TetherKitSession? in defer { self.session = nil } @@ -199,10 +219,11 @@ final class HelperService: NSObject, TetherKitHelperProtocol { session?.stop() } - // MARK: - 内部工具 + // MARK: - Internal helpers - /// 复核授权;不通过时回复错误并把第二个参数置为 true,告诉 App - /// 「这是授权问题,重新弹框再来一次也许就成了」。 + /// Verify authorization; on failure, reply with an error and set the second + /// parameter to true to tell the App "this is an authorization problem -- + /// re-prompting and trying again might succeed". private func authorize(_ data: Data, reply: @escaping (String?, Bool) -> Void) -> Bool { do { try AuthorizationVerifier.verify(externalForm: data) @@ -239,7 +260,8 @@ final class HelperService: NSObject, TetherKitHelperProtocol { guard !notices.isEmpty else { return } stateLock.withLock { pendingNotices.append(contentsOf: notices) - // 上限 200:App 若长时间不来取(比如被挂起),不该让它无限增长。 + // Cap at 200: if the App does not come to fetch for a long time (for + // example, suspended), this must not grow without bound. if pendingNotices.count > 200 { pendingNotices.removeFirst(pendingNotices.count - 200) } @@ -254,7 +276,7 @@ final class HelperService: NSObject, TetherKitHelperProtocol { } } -/// XPC 监听器代理。 +/// XPC listener delegate. final class HelperListenerDelegate: NSObject, NSXPCListenerDelegate { private let service: HelperService @@ -264,14 +286,19 @@ final class HelperListenerDelegate: NSObject, NSXPCListenerDelegate { func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool { - // ★ 为什么这里无条件接受 ★ + // Why connections are accepted unconditionally: // - // 安全性不建立在「谁能连上」,而建立在「每次特权调用都要附带一份用户刚 - // 确认过的授权凭据」。这是刻意的选择:另一条路是校验调用方的代码签名 - // (SMJobBless 的做法),但那依赖证书的 designated requirement,而源码 - // 分发下每台机器编出的 cdhash 都不同,写死的 DR 必然对不上。 + // Security is not built on "who can connect", but on "every privileged + // call must carry a credential the user just confirmed". This is a + // deliberate choice: the other route is to validate the caller's code + // signature (the SMJobBless approach), but that depends on a + // certificate's designated requirement, and under source distribution + // the cdhash produced by each machine's build differs, so a hardcoded + // DR can never match. // - // 对开源、源码分发的工具,凭据复核是更合适的模型:谁编译的都一样安全。 + // For an open-source, source-distributed tool, credential verification + // is the more appropriate model: it is equally safe regardless of who + // compiled it. connection.exportedInterface = NSXPCInterface(with: TetherKitHelperProtocol.self) connection.exportedObject = service connection.resume() diff --git a/gui/Sources/TetherKitHelper/InstallerMode.swift b/gui/Sources/TetherKitHelper/InstallerMode.swift index 61d81f0..b7c3dab 100644 --- a/gui/Sources/TetherKitHelper/InstallerMode.swift +++ b/gui/Sources/TetherKitHelper/InstallerMode.swift @@ -2,28 +2,37 @@ import Darwin import TetherKitIPC import Foundation -/// 「App 内一键安装 / 卸载」的执行入口:本二进制以 `--install` 或 `--uninstall` -/// 参数被 AEWP 拉起时接管进程,把自己所在载荷目录里对应的脚本以真 root 跑一遍。 +/// Execution entry for "one-click install / uninstall from the App": when this +/// binary is launched by AEWP with `--install` or `--uninstall`, it takes over +/// the process and runs the corresponding script from its own payload directory +/// as real root. /// -/// ★ 为什么需要这个模式,而不是让 App 直接 AEWP 安装脚本 ★ +/// Why this mode is needed instead of having the App run the install script +/// through AEWP directly: /// -/// AEWP 的 setuid 载体(security_authtrampoline)给子进程的凭据是 -/// euid=0 / ruid=调用者。而 bash 在 euid ≠ ruid 且未带 -p 时会把 euid 降回 -/// ruid —— 这是防 setuid 脚本的老规矩,zsh、dash 同理。于是脚本开头的 -/// root 检查必然报「需要 root 权限」。**真机踩过,不是理论推演。** +/// AEWP's setuid carrier (security_authtrampoline) gives the child process +/// credentials euid=0 / ruid=caller. bash drops euid back to ruid when +/// euid != ruid and -p is not set -- an old rule against setuid scripts, and +/// zsh/dash behave the same. The root check at the top of the script then +/// inevitably reports "root required". Confirmed on a real machine, not a +/// theoretical deduction. /// -/// 二进制不受这条规矩管。这里以 euid=0 起步,setuid(0) 把 ruid/suid 一并 -/// 归零,得到与 sudo 完全相同的凭据;随后 exec 的脚本连同它的全部子进程 -/// (cp、chown、launchctl)都不再有任何凭据歧义。 +/// Binaries are not subject to that rule. Starting here with euid=0, setuid(0) +/// zeroes ruid/suid as well, yielding credentials identical to sudo; the +/// script exec'd afterward, together with all its child processes (cp, chown, +/// launchctl), then has no credential ambiguity. /// -/// ★ 三个细节 ★ -/// * stderr 并进 stdout:AEWP 只给一根连 stdout 的管道,脚本的 die 全走 -/// stderr,不合流 App 那边就看不到失败原因。放在最前面,连 setuid 失败 -/// 的报错也要能到达管道。 -/// * setgid 必须在 setuid **之前** —— 反过来 euid 已经不是 0,setgid 会失败。 -/// * 脚本路径取自本可执行文件所在目录:载荷是平铺的,拷到哪都成立。 -/// 装进 /Library/PrivilegedHelperTools 的那份旁边没有脚本,会得到明确 -/// 报错而不是误装。 +/// Three details: +/// * stderr is merged into stdout: AEWP provides only a pipe connected to +/// stdout, and the script's die paths go to stderr; without merging, the App +/// side cannot see the failure reason. Do this first so that even a setuid +/// failure reaches the pipe. +/// * setgid must come before setuid -- the other way around, euid is no longer +/// 0 and setgid will fail. +/// * The script path is taken from this executable's directory: the payload is +/// flat, so it works wherever it is copied. The copy installed in +/// /Library/PrivilegedHelperTools has no scripts beside it and will produce +/// a clear error instead of a mis-install. func runInstallerModeIfRequested() { let scriptName: String switch CommandLine.arguments.dropFirst().first { @@ -49,7 +58,7 @@ func runInstallerModeIfRequested() { exit(1) } - // execv 不返回;返回即失败。 + // execv does not return; returning means failure. var argv: [UnsafeMutablePointer?] = [strdup("/bin/bash"), strdup(script), nil] execv("/bin/bash", &argv) perror("execv /bin/bash") diff --git a/gui/Sources/TetherKitHelper/main.swift b/gui/Sources/TetherKitHelper/main.swift index fd59e9b..d7d76ab 100644 --- a/gui/Sources/TetherKitHelper/main.swift +++ b/gui/Sources/TetherKitHelper/main.swift @@ -1,40 +1,46 @@ -// tetherkit-helper —— 以 root 运行的特权 helper。 +// tetherkit-helper -- the privileged helper running as root. // -// ★ 它的 root 从哪来 ★ -// 来自 launchd:/Library/LaunchDaemons/com.tetherkit.helper.plist 声明了 -// MachServices,App 一连上这个 Mach 服务,launchd 就按需把它拉起来, -// 一启动就是 root。**跟用户按没按指纹毫无关系** —— 所以「谁在调用」必须由 -// helper 自己每次复核,见 TetherKitIPC/Authorization.swift。 +// Where its root comes from: +// From launchd: /Library/LaunchDaemons/com.tetherkit.helper.plist declares +// MachServices, so when the App connects to this Mach service, launchd launches +// it on demand, and it starts as root. This has nothing to do with whether the +// user pressed a fingerprint -- so "who is calling" must be verified by the +// helper itself on every request; see TetherKitIPC/Authorization.swift. // -// ★ 为什么不是 SMAppService ★ -// SMPrivilegedExecutables / SMAuthorizedClients 靠代码签名的 designated -// requirement 双向绑定。源码分发(Homebrew formula)下每台机器编译出的 cdhash -// 都不同,写死在 plist 里的 DR 必然对不上。详见 docs/GUI-SPIKE.md 第 3.3 节。 +// Why not SMAppService: +// SMPrivilegedExecutables / SMAuthorizedClients rely on a code-signature +// designated requirement binding in both directions. Under source distribution +// (Homebrew formula), the cdhash produced by each machine's build differs, so a +// DR hardcoded in the plist can never match. See docs/GUI-SPIKE.md section 3.3. import Foundation import TetherKitCore import TetherKitIPC -// ---- 安装模式 ---- +// ---- Installer mode ---- // -// 被 App 以 `--install` 拉起时接管进程(见 InstallerMode.swift),与下面的 -// daemon 模式无关 —— 必须放在最前面,日志捕获、孤儿清理都不该在安装模式里跑。 +// When launched by the App with `--install`, it takes over the process (see +// InstallerMode.swift), unrelated to the daemon mode below -- this must come +// first; log capture and orphan cleanup must not run in installer mode. runInstallerModeIfRequested() -/// 往 stderr 写一行。helper 由 launchd 拉起,stderr 进的是 LaunchDaemon 的 -/// 日志文件 —— 排查「helper 起不来」这类问题时,那是唯一能看到东西的地方。 +/// Write a line to stderr. The helper is launched by launchd, so stderr goes to +/// the LaunchDaemon log file -- when troubleshooting "the helper won't start", +/// that is the only place anything can be seen. func writeToStandardError(_ message: String) { FileHandle.standardError.write(Data((message + "\n").utf8)) } -// ---- 日志 ---- +// ---- Logging ---- // -// 打开捕获,让 App 能在界面上看到库内部的日志。stderr 的输出不受影响。 +// Enable capture so the App can see the library's internal logs in the UI. +// Output to stderr is unaffected. TetherKitLibrary.startLogCapture(level: .info) -// ---- 兜底清理 ---- +// ---- Backstop cleanup ---- // -// 上一次运行若被 SIGKILL,析构不会跑,feth 网卡还留在内核里。这是唯一能救回 -// 那种情况的地方 —— 信号处理器拦不住 SIGKILL。 +// If the previous run was killed with SIGKILL, destructors never ran and feth +// interfaces were left in the kernel. This is the only place that can recover +// from that -- signal handlers cannot catch SIGKILL. do { let removed = try TetherKitLibrary.cleanupOrphanInterfaces() if removed > 0 { @@ -47,16 +53,20 @@ do { let service = HelperService() let delegate = HelperListenerDelegate(service: service) -// ---- 优雅停机 ---- +// ---- Graceful shutdown ---- // -// `launchctl bootout` 发的是 SIGTERM,而 Swift 的 deinit 在进程被终止时不会跑。 -// 不接住它就会把网卡漏在内核里(虽然有落盘登记兜底,但能当场清干净更好)。 +// `launchctl bootout` sends SIGTERM, but Swift's deinit does not run when a +// process is terminated. Not catching it would leak interfaces in the kernel +// (the on-disk registration is still a backstop, but cleaning up on the spot is +// better). // -// 用 DispatchSource 而不是 signal(2) 的处理函数:后者跑在信号上下文里, -// 那里能做的事极其有限(不能加锁、不能分配内存),而我们要做的停机拆除 -// 恰恰两样都要。DispatchSource 把它转成普通队列上的回调,限制就没了。 +// Use DispatchSource instead of a signal(2) handler: the latter runs in signal +// context, where very little is allowed (no locking, no memory allocation), yet +// the shutdown teardown we need to do requires both. DispatchSource turns it +// into a normal queue callback, removing those restrictions. // -// 必须先 SIG_IGN:在 DispatchSource 接手之前,默认行为(终止进程)仍然生效。 +// SIG_IGN must come first: before DispatchSource takes over, the default action +// (terminate the process) is still in effect. signal(SIGTERM, SIG_IGN) let terminationSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) terminationSource.setEventHandler { @@ -72,9 +82,11 @@ listener.resume() writeToStandardError(L(.helperReady, TetherKitLibrary.versionInfo.version)) -// 阻塞在主 runloop 上。 +// Block on the main runloop. // -// 刻意**不**做「空闲一段时间就退出」:会话跑起来之后,helper 拥有 feth 网卡与 -// BPF 描述符,退出就等于把用户的网络断掉。LaunchDaemon 是按需拉起的, -// 一直活着并不会在没人用时占资源 —— 因为那时根本没被拉起来。 +// Deliberately does not "exit after being idle for a while": once a session is +// running, the helper owns feth interfaces and BPF descriptors, and exiting is +// equivalent to cutting the user's network. The LaunchDaemon is launched on +// demand; staying alive does not consume resources when unused -- because in +// that case it would not have been launched at all. dispatchMain() diff --git a/gui/Sources/TetherKitIPC/Authorization.swift b/gui/Sources/TetherKitIPC/Authorization.swift index b891fab..d4b9cc6 100644 --- a/gui/Sources/TetherKitIPC/Authorization.swift +++ b/gui/Sources/TetherKitIPC/Authorization.swift @@ -1,40 +1,50 @@ import Foundation import Security -/// 授权凭据的取得(App 侧)与复核(helper 侧)。 +/// Obtaining (App side) and verifying (helper side) authorization credentials. /// -/// ★ 两侧刻意放在同一个文件里 ★ -/// 它们的差别只有一个标志位(`.interactionAllowed`),而那一位放错就是一个 -/// 安全漏洞。写在一起,改一边时另一边就在眼前。 +/// The two sides are deliberately kept in the same file: +/// They differ by only one flag (`.interactionAllowed`), and setting that flag +/// in the wrong place is a security vulnerability. Kept together, the other +/// side is in view when one is changed. /// -/// ★ 一句必须记住的话:授权 ≠ root ★ -/// `AuthorizationCopyRights` 成功**不改变进程的任何东西** —— uid、euid 一个 -/// 都没动。它产出的只是一张「用户在某时刻通过密码/指纹确认过」的凭据。 -/// 权限本身必须另有来源(这里是 launchd 以 root 拉起的 helper)。 +/// One sentence that must be remembered: authorization != root. +/// A successful `AuthorizationCopyRights` does not change anything about the +/// process -- uid and euid are untouched. All it produces is a credential +/// proving "the user confirmed with a password/fingerprint at some point". +/// The privilege itself must come from elsewhere (here, the helper launched as +/// root by launchd). /// -/// 所以顺序不能反:不是「先弹指纹拿到 root,再去建网卡」,而是「先有常驻的 -/// root helper,每次操作弹指纹拿凭据交给它复核」。 +/// So the order cannot be reversed: it is not "show the fingerprint prompt to +/// get root, then create the interface", but "first have a resident root +/// helper, then on each operation show the fingerprint prompt to obtain a +/// credential and hand it to the helper for verification". -/// 一次授权的持有者。 +/// Holder of one authorization. /// -/// ★ 它存在的唯一理由:外部形式**不是凭据本身,只是一个引用** ★ +/// The only reason it exists: the external form is not the credential itself, +/// only a reference. /// -/// `AuthorizationMakeExternalForm` 产出的 32 字节里没有任何权利信息,它只是 -/// 指向 securityd 里那份授权的一把钥匙。只要 App 这边把 AuthorizationRef 释放 -/// 掉(尤其是带 `.destroyRights` 释放),securityd 里的东西就没了 —— -/// helper 随后 `AuthorizationCreateFromExternalForm` 会失败, -/// 报 `errAuthorizationDenied (-60005)`。 +/// The 32 bytes produced by `AuthorizationMakeExternalForm` contain no rights +/// information; they are just a key pointing at that authorization inside +/// securityd. As soon as the App side releases the AuthorizationRef (especially +/// with `.destroyRights`), the thing in securityd is gone -- the helper's +/// subsequent `AuthorizationCreateFromExternalForm` will fail with +/// `errAuthorizationDenied (-60005)`. /// -/// 所以 AuthorizationRef 必须**活到 XPC 往返结束之后**。用一个类来持有它, -/// 让 ARC 去管这件事,比在每个调用点手写「记得最后再 free」可靠得多。 +/// So the AuthorizationRef must stay alive until the XPC round trip has +/// finished. Holding it in a class and letting ARC manage that is far more +/// reliable than writing "remember to free at the end" at every call site. /// -/// ★ 为什么敢标 @unchecked Sendable ★ -/// HelperInstaller 要把令牌递到后台队列(AEWP 会阻塞在授权框上,不能占主 -/// 线程)。这里没有可变状态 —— 两个存储属性都是 let;AuthorizationRef 本身 -/// 按 Authorization Services 的文档是线程安全的(真正的状态在 securityd -/// 进程里,跨进程调用天然串行化);deinit 由 ARC 保证只跑一次。 +/// Why it is safe to mark @unchecked Sendable: +/// HelperInstaller needs to pass the token to a background queue (AEWP blocks +/// on the authorization dialog and cannot occupy the main thread). There is no +/// mutable state here -- both stored properties are let; AuthorizationRef +/// itself is thread-safe per Authorization Services documentation (the real +/// state lives in the securityd process, and cross-process calls are +/// serialized by nature); ARC guarantees deinit runs exactly once. public final class AuthorizationToken: @unchecked Sendable { - /// 可以跨进程传给 helper 的 32 字节外部形式。 + /// 32-byte external form that can be passed across processes to the helper. public let externalForm: Data private let authorization: AuthorizationRef @@ -44,24 +54,29 @@ public final class AuthorizationToken: @unchecked Sendable { self.externalForm = externalForm } - /// 把底层的 AuthorizationRef 短暂借给需要它本体的 API(目前只有 - /// `AuthorizationExecuteWithPrivileges` 一处 —— 它要的是 ref,不是外部形式)。 + /// Briefly lend the underlying AuthorizationRef to an API that needs the ref + /// itself (currently only `AuthorizationExecuteWithPrivileges`, which takes a + /// ref, not the external form). /// - /// 做成作用域借用而不是直接暴露属性:ref 的生命周期归本类管,谁把它存到 - /// 闭包外面,令牌一释放就是悬垂引用 —— 那种错误编译器查不出来。 + /// Exposed as a scoped borrow rather than a property: the ref's lifetime is + /// owned by this class; storing it outside the closure would become a dangling + /// reference as soon as the token is released -- a mistake the compiler cannot + /// catch. public func withReference(_ body: (AuthorizationRef) throws -> T) rethrows -> T { try body(authorization) } deinit { - // 带 .destroyRights:凭据归 App 所有,用完就销毁,不在进程里留一张 - // 长期有效的通行证。(helper 那边复核时**不能**带这个标志, - // 否则会把这边的授权一起作废 —— 见 AuthorizationVerifier。) + // With .destroyRights: the credential belongs to the App and is destroyed + // when done, leaving no long-lived pass in the process. (The helper must + // not pass this flag when verifying, otherwise it would also invalidate the + // App-side authorization -- see AuthorizationVerifier.) AuthorizationFree(authorization, [.destroyRights]) } } -/// App 侧:向用户请求授权,拿到可以跨进程传递的凭据。 +/// App side: request authorization from the user and obtain a credential that +/// can be passed across processes. public enum AuthorizationBroker { public enum Failure: LocalizedError { case userCancelled @@ -80,29 +95,39 @@ public enum AuthorizationBroker { } } - /// 弹出系统授权框,成功后返回持有这次授权的令牌。 + /// Show the system authorization dialog and, on success, return a token + /// holding this authorization. /// - /// ★ 令牌应当被**缓存复用**,而不是每次操作重新取一次 ★ + /// The token should be cached and reused, not requested again for every + /// operation: /// - /// `system.privilege.admin` 这条规则的实测参数是 `shared = false`、 - /// `timeout = 300`: - /// * `shared = false` —— 凭据**不跨 AuthorizationRef 共享**。每次 - /// `AuthorizationCreate` 建一个新 ref,就必然要用户重新认证一次。 - /// * `timeout = 300` —— 但在**同一个 ref** 上,凭据 5 分钟内有效。 + /// The measured parameters for the `system.privilege.admin` rule are + /// `shared = false`, `timeout = 300`: + /// * `shared = false` -- credentials are not shared across + /// AuthorizationRefs. Creating a new ref with `AuthorizationCreate` + /// for each operation inevitably requires the user to authenticate + /// again. + /// * `timeout = 300` -- but on the same ref, the credential remains + /// valid for 5 minutes. /// - /// 所以「连接、配网络、断开」各弹一次框,纯粹是因为我们每次都新建 ref。 - /// 复用同一个令牌,5 分钟内只需要认证一次。这也是 Apple 自己在 - /// EvenBetterAuthorizationSample 里的做法。 + /// So showing a prompt for "connect", "configure network", and + /// "disconnect" separately is purely because we create a new ref each + /// time. Reusing the same token means only one authentication within 5 + /// minutes. This is also what Apple does in EvenBetterAuthorizationSample. /// - /// 过期之后 helper 侧的复核会失败,调用方据此丢弃缓存、重新取一次即可。 + /// Once expired, helper-side verification will fail, and the caller can + /// discard the cache and request a new one. /// - /// ⚠️ **调用方必须让令牌活到 XPC 往返结束之后。** 令牌一释放,securityd 里的 - /// 授权就没了,helper 还原外部形式时会报 `errAuthorizationDenied (-60005)`。 + /// The caller must keep the token alive until the XPC round trip has + /// finished. Once the token is released, the authorization in securityd is + /// gone and the helper will fail to restore the external form with + /// `errAuthorizationDenied (-60005)`. /// - /// - Parameter prompt: 显示在系统授权框里的一句说明,告诉用户这次授权是干 - /// 什么用的。不传的话框里只有干巴巴的「想要进行更改」。 + /// - Parameter prompt: A sentence shown in the system authorization dialog + /// explaining what this authorization is for. Without it, the dialog shows + /// only a bare "wants to make changes". /// - /// 必须在主线程调用 —— 它会呈现 UI。 + /// Must be called on the main thread -- it presents UI. public static func requestAuthorization( right: String = HelperConstants.privilegedRightName, prompt: String? = nil @@ -112,7 +137,8 @@ public enum AuthorizationBroker { guard createStatus == errAuthorizationSuccess, let authorization else { throw Failure.internalFailure(createStatus) } - // 失败路径上要立刻释放;成功路径上所有权交给 AuthorizationToken。 + // Release immediately on the failure path; hand ownership to + // AuthorizationToken on the success path. var handedOff = false defer { if !handedOff { @@ -138,11 +164,12 @@ public enum AuthorizationBroker { externalForm: withUnsafeBytes(of: &external) { Data($0) }) } - /// 请求权利,必要时弹框。 + /// Request the right, showing a prompt when necessary. /// - /// 单独抽出来是因为环境项的构造要嵌好几层 `withUnsafe*` —— 那些缓冲必须活 - /// 到 `AuthorizationCopyRights` 返回之后,写在主流程里很容易被后来的人 - /// 「顺手整理」成悬垂指针。 + /// Extracted because constructing the environment items nests several + /// `withUnsafe*` layers -- those buffers must stay alive until + /// `AuthorizationCopyRights` returns, and inlining this in the main flow + /// makes it easy for a later "tidy up" to turn them into dangling pointers. private static func copyRights(_ authorization: AuthorizationRef, right: String, prompt: String?) -> OSStatus { @@ -156,9 +183,10 @@ public enum AuthorizationBroker { return withUnsafeMutablePointer(to: &rightItem) { rightPointer in var rights = AuthorizationRights(count: 1, items: rightPointer) - // App 侧**要**带 .interactionAllowed —— 弹框正是我们要的。 - // .preAuthorize 让权利当场就被授予,而不是等到真正使用时, - // 这样凭据才会进入这个 ref 的缓存、供后续操作复用。 + // The App side must include .interactionAllowed -- the prompt is + // exactly what we want. .preAuthorize grants the right on the + // spot rather than at actual use, so the credential enters this + // ref's cache and can be reused for subsequent operations. let flags: AuthorizationFlags = [.extendRights, .interactionAllowed, .preAuthorize] guard prompt != nil else { @@ -184,7 +212,7 @@ public enum AuthorizationBroker { } } -/// helper 侧:复核调用方递过来的凭据。 +/// Helper side: verify a credential handed over by the caller. public enum AuthorizationVerifier { public enum Failure: LocalizedError { case malformedCredential @@ -203,20 +231,27 @@ public enum AuthorizationVerifier { } } - /// 校验凭据里确实已经包含指定权利。不包含就抛错。 + /// Verify that the credential already contains the specified right. Throws + /// if it does not. /// - /// ★ 三条必须照做的细节 ★ + /// Three details that must be followed: /// - /// 1. **绝不能带 `.interactionAllowed`。** daemon 没有 UI 会话;真让它能 - /// 弹框,等于任何能连上 Mach 服务的进程都能随意触发系统授权弹框骚扰 - /// 用户。这一步只查「这份凭据里已经有这项权利了吗」,不获取新权利。 + /// 1. Never include `.interactionAllowed`. A daemon has no UI session; if + /// it could show a prompt, any process able to connect to the Mach + /// service could trigger system authorization dialogs at will to harass + /// the user. This step only checks "does this credential already hold + /// this right?" -- it does not acquire new rights. /// - /// 2. **`AuthorizationFree` 不能带 `.destroyRights`。** 凭据归 App 所有, - /// helper 只是借来核对;带上会把 App 那边的授权一起作废。 + /// 2. `AuthorizationFree` must not include `.destroyRights`. The + /// credential belongs to the App; the helper only borrows it for + /// checking. Including the flag would also invalidate the App-side + /// authorization. /// - /// 3. 复核是**每次特权调用**都要做的。helper 的 root 来自 launchd, - /// 跟用户按没按指纹毫无关系 —— 它一启动就是 root,任何能连上 Mach - /// 服务的进程都能发请求。所以「谁在调用」只能由 helper 自己回答。 + /// 3. Verification must happen on every privileged call. The helper's root + /// comes from launchd and has nothing to do with whether the user + /// pressed a fingerprint -- it is root from the moment it starts, and + /// any process able to connect to the Mach service can send requests. + /// So "who is calling" can only be answered by the helper itself. public static func verify(externalForm data: Data, right: String = HelperConstants.privilegedRightName) throws { guard data.count == MemoryLayout.size else { @@ -233,7 +268,7 @@ public enum AuthorizationVerifier { guard restoreStatus == errAuthorizationSuccess, let authorization else { throw Failure.restoreFailed(restoreStatus) } - // 注意:**不带** .destroyRights,见上面第 2 条。 + // Note: no .destroyRights; see detail 2 above. defer { AuthorizationFree(authorization, []) } var name = Array(right.utf8CString) @@ -242,7 +277,7 @@ public enum AuthorizationVerifier { value: nil, flags: 0) return withUnsafeMutablePointer(to: &item) { itemPointer in var rights = AuthorizationRights(count: 1, items: itemPointer) - // 只有 .extendRights,**没有** .interactionAllowed,见上面第 1 条。 + // Only .extendRights, no .interactionAllowed; see detail 1 above. return AuthorizationCopyRights(authorization, &rights, nil, [.extendRights], nil) } } diff --git a/gui/Sources/TetherKitIPC/HelperConstants.swift b/gui/Sources/TetherKitIPC/HelperConstants.swift index b97c716..9f50a03 100644 --- a/gui/Sources/TetherKitIPC/HelperConstants.swift +++ b/gui/Sources/TetherKitIPC/HelperConstants.swift @@ -1,54 +1,68 @@ import Foundation -/// App 与 helper 之间约定死的一组常量。 +/// A set of constants agreed on between the App and the helper. /// -/// 单独成文件是为了让「改一个名字要同步改哪些地方」这件事只有一个答案 —— -/// 这些字符串同时出现在 LaunchDaemon 的 plist、安装脚本和两端代码里, -/// 任何一处不同步的表现都是「连不上 helper」,且没有任何有用的报错。 +/// Kept in a single file so that "changing a name means synchronizing which +/// places" has exactly one answer -- these strings appear simultaneously in the +/// LaunchDaemon plist, install scripts, and code on both sides, and any place +/// left out of sync manifests as "cannot connect to helper" with no useful error. public enum HelperConstants { - /// Mach 服务名。必须与 /Library/LaunchDaemons/*.plist 里的 MachServices 键一致。 + /// Mach service name. Must match the MachServices key in + /// /Library/LaunchDaemons/*.plist. public static let machServiceName = "com.tetherkit.helper" - /// helper 可执行文件的安装路径。 + /// Install path of the helper executable. /// - /// /Library/PrivilegedHelperTools 是 Apple 给特权 helper 的约定位置, - /// 只有 root 可写。 + /// /Library/PrivilegedHelperTools is Apple's conventional location for + /// privileged helpers; only root can write there. public static let helperExecutablePath = "/Library/PrivilegedHelperTools/com.tetherkit.helper" - /// LaunchDaemon 配置文件路径。 + /// LaunchDaemon configuration file path. public static let launchDaemonPlistPath = "/Library/LaunchDaemons/com.tetherkit.helper.plist" - /// 特权操作所要求的授权权利。 + /// Authorization right required for privileged operations. /// - /// ★ 为什么用系统内置的 system.privilege.admin,而不是自定义权利 ★ - /// 自定义权利要先用 AuthorizationRightSet 写进策略数据库,而那本身就需要 - /// 管理员权限 —— 于是就有了「安装授权需要授权」的先有鸡还是先有蛋问题。 - /// system.privilege.admin 的规则是 authenticate-admin,弹的正是我们想要的 - /// 密码 / Touch ID 框,语义也贴切:「这是一次需要管理员身份的操作」。 + /// Why the built-in system.privilege.admin, not a custom right: + /// A custom right must first be written into the policy database with + /// AuthorizationRightSet, which itself requires administrator privileges + /// -- creating a chicken-and-egg problem of "authorization needs + /// authorization to install". The rule for system.privilege.admin is + /// authenticate-admin, which shows exactly the password / Touch ID prompt + /// we want, and its semantics fit: "this is an operation that needs an + /// administrator identity". public static let privilegedRightName = "system.privilege.admin" - /// XPC 接口的修订号。**每次改动 TetherKitHelperProtocol 都要加一。** + /// XPC interface revision. Increment this on every change to + /// TetherKitHelperProtocol. /// - /// 为什么需要它:helper 是装在系统目录里的,升级 App 时如果忘了重装 helper, - /// 两端的方法签名就对不上 —— 表现是调用卡住或者直接崩,完全看不出是版本问题。 - /// 有了这个号,App 一连上就能发现不匹配并明确告诉用户「请重新安装特权组件」。 + /// Why this is needed: the helper is installed in a system directory, so if + /// the App is upgraded but the helper is not reinstalled, the method + /// signatures on the two sides no longer match -- manifesting as calls that + /// hang or crash outright, with nothing indicating a version problem. With + /// this number, the App can detect the mismatch as soon as it connects and + /// tell the user clearly to reinstall the privileged component. /// - /// 修订历史: - /// 1 —— 初版 - /// 2 —— 特权方法的应答从 (String?) 改成 (String?, Bool),区分授权失败 - /// 3 —— 新增 setLanguage,让 helper 的提示与库日志跟随界面语言 + /// Revision history: + /// 1 -- initial version + /// 2 -- privileged method replies changed from (String?) to (String?, Bool) + /// to distinguish authorization failures + /// 3 -- added setLanguage so helper notices and library logs follow the UI + /// language public static let protocolRevision = 3 - /// 把修订号编进版本串。 + /// Encode the revision into the version string. /// - /// 刻意复用现成的 `helperVersion` 方法而不是新增一个 —— 新增方法本身就是 - /// 一次协议变更,旧 helper 根本没有它,那就又回到了「对不上还查不出来」。 - /// 用旧 helper 也一定会应答的这个方法,才能可靠地识别出旧 helper。 + /// Intentionally reuses the existing `helperVersion` method instead of + /// adding a new one -- adding a method is itself a protocol change, and an + /// old helper does not have it, which would bring back "mismatch but no way + /// to detect it". Using this method, which any old helper will still answer, + /// is the only way to reliably identify an old helper. public static func encodeVersion(_ version: String) -> String { "\(protocolRevision)|\(version)" } - /// 解析版本串。旧 helper 返回的串里没有分隔符,此时修订号记作 0。 + /// Parse a version string. Strings returned by old helpers have no separator, + /// in which case the revision is recorded as 0. public static func decodeVersion(_ encoded: String) -> (revision: Int, version: String) { guard let separator = encoded.firstIndex(of: "|"), let revision = Int(encoded[encoded.startIndex.. `"0.1.4"`. /// - /// ★ 为什么不直接比整串 ★ - /// 串里除了版本号还带着 C++ 标准与最低 macOS 版本 —— 那是**构建配置**, - /// 不是版本。拿整串当判据的话,换个编译选项重建一次就会冒出一个 - /// 「组件该更新了」的假警报,而用户点下去什么也不会变。 + /// Why not compare the whole string: + /// Besides the version number, the string also carries the C++ standard and + /// minimum macOS version -- that is build configuration, not the version. + /// Using the whole string as the criterion would produce a spurious + /// "component needs updating" warning after a rebuild with different + /// compile options, and nothing would change when the user clicked it. /// - /// 取不到(串里没有带点的数字)时退回整串:宁可误报也不要漏报 —— 漏报 - /// 意味着用户一直在跑升级前的那份库,且毫不知情。 + /// Falls back to the whole string when nothing can be extracted (no dotted + /// number in the string): better a false positive than a missed one -- a + /// missed one means the user keeps running the pre-upgrade library without + /// knowing it. public static func semanticVersion(of text: String) -> String { - // 至少要有一个点,否则 "C++23" 这种也会被当成版本号。 + // Must contain at least one dot, otherwise values like "C++23" would be + // mistaken for a version number. guard let range = text.range(of: "[0-9]+(\\.[0-9]+)+", options: .regularExpression) else { return text.trimmingCharacters(in: .whitespacesAndNewlines) } return String(text[range]) } - /// XPC 调用的超时(秒)。 + /// XPC call timeout (seconds). /// - /// 取 30 秒是因为最慢的一次调用是 DHCP 配置:库内部最多等 10 秒租约, - /// 加上 USB 握手与网卡创建,留 3 倍余量。 + /// 30 seconds because the slowest call is DHCP configuration: the library + /// waits up to 10 seconds for a lease internally; adding USB handshake and + /// interface creation, this leaves a 3x margin. public static let requestTimeout: TimeInterval = 30 } diff --git a/gui/Sources/TetherKitIPC/HelperProtocol.swift b/gui/Sources/TetherKitIPC/HelperProtocol.swift index 277566e..53332eb 100644 --- a/gui/Sources/TetherKitIPC/HelperProtocol.swift +++ b/gui/Sources/TetherKitIPC/HelperProtocol.swift @@ -1,72 +1,92 @@ import Foundation -/// App 调用 helper 的 XPC 接口。 +/// XPC interface called by the App on the helper. /// -/// ★ 哪些方法需要授权,哪些不需要 ★ +/// Which methods require authorization and which do not: /// -/// 带 `authorization` 参数的都要 —— 它是 App 通过 AuthorizationCopyRights -/// 拿到的凭据的外部形式(32 字节),helper 会**复核**它确实包含所需权利。 +/// Every method with an `authorization` parameter requires it -- the parameter +/// is the external form (32 bytes) of a credential the App obtained through +/// AuthorizationCopyRights, and the helper verifies that it actually contains +/// the required right. /// -/// 探测类方法(helperVersion / status / queryNetwork / drainFeed)刻意**不** -/// 要求授权。理由是:如果连「helper 装没装」都要先弹一次指纹,用户就分不清 -/// 「helper 没装」和「授权没过」这两种完全不同的失败了。这些方法只读, -/// 泄漏的信息也仅限于本机网络状态。 +/// Probe methods (helperVersion / status / queryNetwork / drainFeed) +/// intentionally do not require authorization. The reason: if even "is the +/// helper installed?" required a fingerprint prompt first, the user could not +/// tell "helper not installed" apart from "authorization failed" -- two +/// completely different failures. These methods are read-only, and the +/// information they expose is limited to this machine's network state. /// -/// ★ 特权方法的应答为什么是 (String?, Bool) 两个值 ★ -/// 第二个值表示「这次失败是授权问题」。App 需要区分这两种情况:授权过期了 -/// 该重新弹框重试,而操作本身失败(比如没插设备)重试多少次都一样。 -/// 混在一条错误消息里,App 只能去匹配字符串 —— 那是最脆的一种耦合。 +/// Why privileged methods reply with two values, (String?, Bool): +/// The second value indicates "this failure was an authorization problem". The +/// App needs to distinguish the two cases: an expired authorization can be +/// retried after re-prompting, while an operation failure (for example, no +/// device connected) will not change no matter how many times it is retried. +/// Mixing them into one error message would force the App to match strings -- +/// the most fragile kind of coupling. /// -/// ★ 为什么错误用 String? 而不是 NSError ★ -/// 跨 XPC 传 NSError 要求两端都能反序列化它的 userInfo,一旦里面混进不可 -/// 编码的对象就是运行时异常。而我们需要传给用户看的本来就只是一句中文, -/// 直接传字符串既简单又不会失败。nil 表示成功。 +/// Why errors use String? instead of NSError: +/// Passing NSError over XPC requires both sides to deserialize its userInfo; +/// if that contains a non-encodable object it becomes a runtime exception. And +/// what we need to show the user is already just a message string, so passing +/// a plain string is simple and cannot fail. nil means success. @objc public protocol TetherKitHelperProtocol { - /// 连通性探测。**不要求授权** —— 否则「没装」和「没授权」两种失败会混在一起。 + /// Connectivity probe. Does not require authorization -- otherwise "not + /// installed" and "not authorized" would be indistinguishable. func helperVersion(reply: @escaping (String) -> Void) - /// 运行环境预检(root 状态、feth 的创建期 sysctl、MTU 上限)。 + /// Runtime environment preflight (root status, feth creation-time sysctls, + /// MTU limit). func environment(reply: @escaping (Data?, String?) -> Void) - /// 枚举 RNDIS 设备。 + /// Enumerate RNDIS devices. /// - /// 由 helper 而不是 App 来枚举:App 侧也能枚举(不需要 root),但会话跑起来 - /// 之后设备已被 helper 独占,App 再去读字符串描述符只会失败。统一走 helper - /// 就没有这个不一致。 + /// Enumeration is done by the helper rather than the App: the App side can + /// also enumerate (no root required), but once a session is running the + /// device is held exclusively by the helper and the App's attempts to read + /// string descriptors will fail. Going through the helper avoids that + /// inconsistency. func listDevices(reply: @escaping (Data?, String?) -> Void) - /// 启动 RNDIS 会话。**需要授权。** + /// Start an RNDIS session. Requires authorization. func startSession(authorization: Data, configuration: Data, reply: @escaping (String?, Bool) -> Void) - /// 停止会话并销毁虚拟网卡。**需要授权。** + /// Stop the session and tear down the virtual interfaces. Requires + /// authorization. func stopSession(authorization: Data, reply: @escaping (String?, Bool) -> Void) - /// 取会话状态快照。 + /// Fetch a session status snapshot. func sessionStatus(reply: @escaping (Data?, String?) -> Void) - /// 给网卡下发上网方式(DHCP / 静态 IP / 撤销)。**需要授权。** + /// Apply a network access mode (DHCP / manual IP / revoke) to an interface. + /// Requires authorization. /// - /// DHCP 模式下这一调用会阻塞到拿到租约或超时(库内部上限 10 秒), - /// 因此 helper 侧不能把它排在会串行阻塞其它请求的队列上。 + /// In DHCP mode this call blocks until a lease is obtained or it times out + /// (library-internal limit 10 seconds), so the helper side must not place it + /// on a queue that would serially block other requests. func applyNetwork(authorization: Data, interface: String, configuration: Data, reply: @escaping (String?, Bool) -> Void) - /// 回读网卡真实生效的 IP 状态。 + /// Read back the interface's effective IP state. func queryNetwork(interface: String, reply: @escaping (Data?, String?) -> Void) - /// 取走 helper 侧积压的日志与提示。 + /// Drain logs and notices accumulated on the helper side. func drainFeed(reply: @escaping (Data?) -> Void) - /// 告诉 helper 用哪种语言渲染它产生的文字。**不要求授权** —— 它只影响 - /// 文案,改不了任何行为。 + /// Tell the helper which language to use when rendering the text it + /// produces. Does not require authorization -- it affects only wording, not + /// behavior. /// - /// 为什么必须有这一条:helper 以 root 跑在 launchd 下,看不到用户的语言 - /// 偏好,而它产生的提示(「会话已停止」)与 libtetherkit 的日志都会原样 - /// 显示在 App 的日志卡里。不同步的话界面是一种语言、日志是另一种。 + /// Why this is necessary: the helper runs as root under launchd and cannot + /// see the user's language preference, yet the notices it produces and the + /// libtetherkit logs are shown as-is in the App's log card. Without keeping + /// them in sync, the UI and the logs would speak different languages. /// - /// 参数是 `Language` 的 rawValue(`"chinese"` / `"english"`)。传字符串而 - /// 不是整数:将来加语言时,旧 helper 收到不认识的值会原样忽略,而不是 - /// 把它当成某个碰巧存在的枚举值。 + /// The parameter is the rawValue of `Language` (currently `"english"`). + /// Passing a string rather than an integer means a future language can be + /// added so that an old helper receiving an unrecognized value ignores it + /// instead of treating it as some enum case that happens to exist. An + /// unrecognized value (including the removed `"chinese"` spelling) leaves + /// the helper rendering in its current language. func setLanguage(_ rawValue: String, reply: @escaping () -> Void) } diff --git a/gui/Sources/TetherKitIPC/Models.swift b/gui/Sources/TetherKitIPC/Models.swift index 1bbad2c..8bbf674 100644 --- a/gui/Sources/TetherKitIPC/Models.swift +++ b/gui/Sources/TetherKitIPC/Models.swift @@ -1,27 +1,32 @@ import Foundation -// App 与 helper 之间传输的数据模型。 +// Data models transferred between the App and the helper. // -// ★ 为什么全部走 Codable + Data,而不是 NSSecureCoding 类 ★ -// XPC 只接受 plist 类型或实现了 NSSecureCoding 的类,后者要求两端注册允许的 -// 类集合,写错一个就是运行时的「不允许的类」异常,而且报错信息很难定位。 -// 直接把 Codable 结构体编成 JSON Data 传过去,两端共用同一份类型定义, -// 校验交给 JSONDecoder —— 简单,且加字段天然向后兼容。 - -// MARK: - 会话 - -/// RNDIS 会话的启动配置。字段与 C ABI 的 tk_session_config_t 一一对应。 +// Why everything goes through Codable + Data, not NSSecureCoding classes: +// XPC only accepts plist types or classes that implement NSSecureCoding, and the +// latter requires both sides to register the allowed class set. Getting one wrong +// becomes a runtime "disallowed class" exception whose message is hard to pin +// down. Encoding Codable structs into JSON Data and sharing the same type +// definitions on both sides leaves validation to JSONDecoder -- simple, and +// adding fields is backward compatible by default. + +// MARK: - Session + +/// Startup configuration for an RNDIS session. Fields correspond one-to-one with +/// tk_session_config_t in the C ABI. public struct SessionConfiguration: Codable, Hashable, Sendable { - /// 设备筛选。全为 0 表示用第一个找到的 RNDIS 设备。 + /// Device filter. All zeros mean use the first RNDIS device found. public var vendorID: UInt16 public var productID: UInt16 - /// 总线号与设备地址必须同时给出,用于区分两台同型号设备。 + /// Bus number and device address must be given together to distinguish two + /// devices of the same model. public var busNumber: UInt8 public var deviceAddress: UInt8 - /// 希望使用的 MTU。设备装不下时会被协商下调。 + /// Desired MTU. Negotiated down when the device cannot accommodate it. public var mtu: UInt32 - /// 是否把系统侧网卡的 MAC 设为设备汇报的地址。 + /// Whether to set the system-side interface MAC to the address reported by the + /// device. public var adoptDeviceMAC: Bool public init(vendorID: UInt16 = 0, @@ -39,7 +44,7 @@ public struct SessionConfiguration: Codable, Hashable, Sendable { } } -/// 会话生命周期状态。原始值与 C 的 tk_run_state_t 对齐。 +/// Session lifecycle state. Raw values align with C's tk_run_state_t. public enum RunState: Int32, Codable, Sendable { case idle = 0 case starting = 1 @@ -48,34 +53,36 @@ public enum RunState: Int32, Codable, Sendable { case stopped = 4 case failed = 5 - /// 是否处于「正在忙」的过渡态 —— 界面据此禁用按钮并显示进度。 + /// Whether this is a "busy" transitional state -- the UI uses this to disable + /// buttons and show progress. public var isTransitional: Bool { self == .starting || self == .stopping } } -/// RNDIS 主机侧状态。原始值与 C 的 tk_rndis_state_t 对齐。 +/// RNDIS host-side state. Raw values align with C's tk_rndis_state_t. public enum RndisState: Int32, Codable, Sendable { case uninitialized = 0 case initializing = 1 case initialized = 2 - /// 数据开始流动。 + /// Data has started flowing. case dataInitialized = 3 case halting = 4 } -/// 会话状态快照。 +/// Session status snapshot. public struct SessionStatus: Codable, Hashable, Sendable { public var runState: RunState public var rndisState: RndisState public var linkUp: Bool - /// 数据搬运是否处于暂停(链路 down 或设备软复位期间)。 + /// Whether data movement is paused (while the link is down or during a device + /// soft reset). public var paused: Bool - /// 系统侧网卡名 —— 用户要在这张上配 IP。 + /// System-side interface name -- the user configures IP on this one. public var systemInterface: String - /// 驱动侧网卡名,仅用于排障展示。 + /// Driver-side interface name, shown only for troubleshooting. public var driverInterface: String - /// 形如 "aa:bb:cc:dd:ee:ff";未协商时为空。 + /// Formatted as "aa:bb:cc:dd:ee:ff"; empty before negotiation. public var deviceMAC: String public var mtu: UInt32 public var linkSpeedMbps: UInt32 @@ -92,14 +99,16 @@ public struct SessionStatus: Codable, Hashable, Sendable { public var linkKernelDrops: UInt64 public var txBackpressure: UInt64 - /// 采样时刻的单调纳秒计数。**算速率必须用它做分母**,不能用界面定时器的 - /// 周期 —— 两次拉取之间的真实间隔会被调度拉长,那样算出来的速率偏高。 + /// Monotonic nanosecond count at sample time. Rate calculations must use this + /// as the denominator, not the UI timer period -- the real interval between + /// two fetches can be stretched by scheduling, which would make the rate read + /// high. public var monotonicNanos: Int64 - /// runState == .failed 时的原因。 + /// Reason when runState == .failed. public var fatalMessage: String - /// 未连接时的空状态。 + /// Empty state when not connected. public static let idle = SessionStatus( runState: .idle, rndisState: .uninitialized, linkUp: false, paused: false, systemInterface: "", driverInterface: "", deviceMAC: "", mtu: 0, linkSpeedMbps: 0, @@ -140,29 +149,32 @@ public struct SessionStatus: Codable, Hashable, Sendable { } } -// MARK: - 设备 +// MARK: - Device -/// 一台被识别为 RNDIS 的 USB 设备。 +/// A USB device identified as RNDIS. public struct DeviceDescriptor: Codable, Hashable, Identifiable, Sendable { public var vendorID: UInt16 public var productID: UInt16 public var busNumber: UInt8 public var deviceAddress: UInt8 - /// 厂商名 / 产品名 / 序列号是尽力而为的:读它们要打开设备,设备被占用时 - /// 会拿不到 —— 但 helper 会回填上次成功读到的值,所以连接前后名字保持 - /// 稳定。连回填值都没有(helper 启动后从未读到过)才是空串,界面此时 - /// 回落到显示 `description`。 + /// Manufacturer / product / serial are best-effort: reading them requires + /// opening the device, which fails while it is held -- but the helper + /// backfills the last successfully read values, so names stay stable across a + /// connection. Only when even the backfilled value is missing (never read + /// since the helper started) is this an empty string, and the UI falls back + /// to showing `description`. public var manufacturer: String public var product: String public var serial: String - /// 形如 "Bus 020 Device 003: 18d1:4ee4",任何情况下都可用。 + /// Formatted as "Bus 020 Device 003: 18d1:4ee4"; always available. public var summary: String public var usedAndroidQuirk: Bool - /// 总线 + 地址唯一确定一台已连接的设备;同型号两台也能区分。 + /// Bus + address uniquely identify a connected device; distinguishes two of + /// the same model. public var id: String { "\(busNumber).\(deviceAddress)" } - /// 界面上显示的主标题。 + /// Primary title shown in the UI. public var displayName: String { if !product.isEmpty { return manufacturer.isEmpty ? product : "\(manufacturer) \(product)" @@ -185,13 +197,13 @@ public struct DeviceDescriptor: Codable, Hashable, Identifiable, Sendable { } } -// MARK: - 网络配置 +// MARK: - Network configuration -/// 上网方式。原始值与 C 的 tk_ip_mode_t 对齐。 +/// Network access mode. Raw values align with C's tk_ip_mode_t. public enum IPMode: Int32, Codable, CaseIterable, Sendable { case dhcp = 0 case manual = 1 - /// 撤销配置。 + /// Revoke configuration. case none = 2 public var displayName: String { @@ -203,19 +215,21 @@ public enum IPMode: Int32, Codable, CaseIterable, Sendable { } } -/// 网卡的上网方式配置。 +/// Network access configuration for an interface. public struct NetworkConfiguration: Codable, Hashable, Sendable { public var mode: IPMode - /// 以下四项仅在 mode == .manual 时使用。 + /// The following four fields are used only when mode == .manual. public var address: String public var netmask: String public var router: String public var dnsServers: [String] - /// 是否把**全局**默认路由也指向本网卡。 + /// Whether to also point the global default route at this interface. /// - /// 不开时只有一条绑定到本接口的 scoped 默认路由。只有在同时存在更高优先级的 - /// 连通服务(Wi-Fi、VPN)时才需要开 —— 而 USB 网络共享的典型场景恰恰是 - /// 没有别的网络可用,那时本网卡自然就是主服务。 + /// When off, there is only a scoped default route bound to this interface. + /// This is needed only when a higher-priority connected service (Wi-Fi, VPN) + /// also exists -- and the typical USB tethering scenario is exactly the case + /// with no other network available, where this interface is naturally the + /// primary service. public var setDefaultRoute: Bool public static let dhcp = NetworkConfiguration(mode: .dhcp) @@ -235,22 +249,24 @@ public struct NetworkConfiguration: Codable, Hashable, Sendable { } } -/// 网卡当前**真实生效**的状态。 +/// The interface's currently effective state. /// -/// 刻意不复述「我们下发了什么」而是回读系统:静态模式下 DNS 能不能生效取决于 -/// IPMonitor 认不认这个服务,只有回读才能给用户准确的反馈。 +/// Intentionally reads back from the system rather than restating what we +/// applied: in manual mode, whether DNS takes effect depends on whether +/// IPMonitor recognizes this service, so only a read-back can give the user +/// accurate feedback. public struct NetworkState: Codable, Hashable, Sendable { public var hasAddress: Bool public var address: String public var netmask: String public var router: String public var dnsServers: [String] - /// IPConfiguration 汇报的配置方式("DHCP" / "MANUAL" / "")。 + /// Configuration method reported by IPConfiguration ("DHCP" / "MANUAL" / ""). public var method: String - /// IPConfiguration 汇报的服务状态(如 "BOUND")。 + /// Service state reported by IPConfiguration (for example "BOUND"). public var serviceState: String public var hasDefaultRoute: Bool - /// 全局默认路由当前是否指向本网卡。 + /// Whether the global default route currently points at this interface. public var isPrimaryDefaultRoute: Bool public static let empty = NetworkState( @@ -272,7 +288,7 @@ public struct NetworkState: Codable, Hashable, Sendable { } } -// MARK: - 日志与事件 +// MARK: - Logs and events public enum LogLevel: Int32, Codable, Comparable, Sendable { case trace = 0 @@ -313,15 +329,18 @@ public struct LogEntry: Codable, Hashable, Identifiable, Sendable { } } -/// helper 一次「取走待处理内容」的应答。 +/// Response for one helper "drain pending content" call. /// -/// 事件与日志合并成一次调用,是为了把 XPC 往返次数压到每个刷新周期一次 —— -/// 界面每 500 ms 刷一次,分开取就是双倍的进程间往返。 +/// Events and logs are merged into a single call to keep XPC round trips to one +/// per refresh cycle -- the UI refreshes every 500 ms, and fetching them +/// separately would double the interprocess round trips. public struct HelperFeed: Codable, Sendable { public var logs: [LogEntry] - /// 被丢弃的日志条数(缓冲写满时丢最旧的)。界面据此提示「日志有缺口」。 + /// Number of log entries dropped (oldest dropped when the buffer was full). + /// The UI uses this to indicate that the log has gaps. public var droppedLogs: UInt64 - /// 已发生但尚未被界面消费的关键事件,用文字形式给出。 + /// Key events that have occurred but not yet been consumed by the UI, given + /// as text. public var notices: [String] public static let empty = HelperFeed(logs: [], droppedLogs: 0, notices: []) @@ -333,13 +352,14 @@ public struct HelperFeed: Codable, Sendable { } } -// MARK: - 环境 +// MARK: - Environment -/// 运行环境预检结果。 +/// Runtime environment preflight result. public struct EnvironmentReport: Codable, Hashable, Sendable { public var isRoot: Bool public var sysctlsOK: Bool - /// sysctl 不合格时的具体说明(含修正命令)。 + /// Specific details when sysctls are not acceptable (including remediation + /// commands). public var sysctlDetail: String public var fethMaxMTU: UInt32 public var version: String diff --git a/gui/Tests/TetherKitIPCTests/AuthorizationTests.swift b/gui/Tests/TetherKitIPCTests/AuthorizationTests.swift index d0d7ff0..4135b56 100644 --- a/gui/Tests/TetherKitIPCTests/AuthorizationTests.swift +++ b/gui/Tests/TetherKitIPCTests/AuthorizationTests.swift @@ -3,27 +3,31 @@ import XCTest @testable import TetherKitIPC -/// 授权凭据生命周期的回归测试。 +/// Regression tests for the authorization credential lifecycle. /// -/// ★ 这里钉住的是一个真实踩过的坑 ★ -/// `AuthorizationMakeExternalForm` 产出的 32 字节**不是凭据本身**,只是指向 -/// securityd 里那份授权的一把钥匙。App 侧一旦提前把 AuthorizationRef 释放 -/// (尤其是带 `.destroyRights`),helper 还原时就会失败 —— 而失败信息只有一个 -/// `-60005`,从代码上完全看不出「是被自己提前销毁的」。 +/// This pins down a real pitfall that was hit: +/// The 32 bytes produced by `AuthorizationMakeExternalForm` are not the +/// credential itself, only a key pointing at that authorization inside +/// securityd. If the App side releases the AuthorizationRef early (especially +/// with `.destroyRights`), the helper's restore will fail -- and the failure +/// message is just `-60005`, with nothing in the code indicating "it was +/// destroyed by us ahead of time". /// -/// 这些用例**不会弹授权框**:只调 `AuthorizationCreate`(建立空的授权会话), -/// 不调 `AuthorizationCopyRights`,所以不需要任何用户交互,可以放进 CI。 +/// These cases never show an authorization dialog: they only call +/// `AuthorizationCreate` (to establish an empty authorization session), never +/// `AuthorizationCopyRights`, so they need no user interaction and can run in +/// CI. final class AuthorizationTests: XCTestCase { - /// 把 AuthorizationRef 外部化。 + /// Externalize an AuthorizationRef. private func externalForm(of authorization: AuthorizationRef) throws -> Data { var external = AuthorizationExternalForm() let status = AuthorizationMakeExternalForm(authorization, &external) try XCTSkipUnless(status == errAuthorizationSuccess, - "AuthorizationMakeExternalForm 失败(\(status)),跳过") + "AuthorizationMakeExternalForm failed (\(status)), skipping") return withUnsafeBytes(of: &external) { Data($0) } } - /// 模拟 helper 侧的还原,返回状态码。 + /// Simulate the helper-side restore and return the status code. private func restore(_ data: Data) -> OSStatus { var external = AuthorizationExternalForm() _ = withUnsafeMutableBytes(of: &external) { destination in @@ -37,7 +41,8 @@ final class AuthorizationTests: XCTestCase { return status } - /// 令牌活着时,另一侧能还原出来。这是正常路径。 + /// While the token is alive, the other side can restore it. This is the + /// normal path. func testExternalFormRestorableWhileTokenAlive() throws { var authorization: AuthorizationRef? XCTAssertEqual(AuthorizationCreate(nil, nil, [], &authorization), errAuthorizationSuccess) @@ -47,15 +52,17 @@ final class AuthorizationTests: XCTestCase { externalForm: try externalForm(of: reference)) XCTAssertEqual(restore(token.externalForm), errAuthorizationSuccess, - "令牌还活着的时候,helper 侧必须能还原出凭据") + "while the token is alive, the helper side must be able to restore the credential") withExtendedLifetime(token) {} } - /// 令牌被释放后就还原不出来了 —— 这正是当初 -60005 的成因。 + /// Once the token is released, it can no longer be restored -- this was + /// exactly the cause of -60005. /// - /// 反过来说:这条用例一旦变红,说明「提前释放也没事」,那 AuthorizationToken - /// 这一整层就没有存在意义了,应该先搞清楚系统行为变了什么再动它。 + /// Conversely, if this case ever turns red, it means "early release is fine + /// now", and the entire AuthorizationToken layer has no reason to exist; the + /// system behavior change should be understood before touching it. func testExternalFormUnusableAfterTokenReleased() throws { var authorization: AuthorizationRef? XCTAssertEqual(AuthorizationCreate(nil, nil, [], &authorization), errAuthorizationSuccess) @@ -66,9 +73,9 @@ final class AuthorizationTests: XCTestCase { let token = AuthorizationToken(authorization: reference, externalForm: try externalForm(of: reference)) capturedForm = token.externalForm - } // token 在这里析构,连同权利一起销毁 + } // token deinit runs here, destroying the rights with it XCTAssertEqual(restore(capturedForm), errAuthorizationDenied, - "令牌释放后外部形式必须失效;当初就是它导致了 -60005") + "the external form must be invalid after the token is released; this was the cause of -60005") } } diff --git a/gui/Tests/TetherKitIPCTests/HelperConstantsTests.swift b/gui/Tests/TetherKitIPCTests/HelperConstantsTests.swift index 820802f..6dcc59a 100644 --- a/gui/Tests/TetherKitIPCTests/HelperConstantsTests.swift +++ b/gui/Tests/TetherKitIPCTests/HelperConstantsTests.swift @@ -2,17 +2,21 @@ import XCTest @testable import TetherKitIPC -/// helper 版本串的编解码与比较。 +/// Encoding/decoding and comparison of the helper version string. /// -/// ★ 这些用例钉住的是什么 ★ -/// 「装着的组件是哪一版」是 App 唯一能问到的事实(`helperVersion` 是旧组件 -/// 也一定会应答的那个方法),后面两个判断全靠它:协议对不上要挡在引导页, -/// 版本对不上要点亮「更新特权组件」。解析写错的表现都是**安静地不报**—— -/// 要么把旧组件当成新的(用户一直跑着升级前的库),要么天天弹一个点了也 -/// 没用的更新提示。两种都不会有任何报错,只能靠用例守。 +/// What these cases pin down: +/// "Which version of the component is installed" is the only fact the App can +/// ask for (`helperVersion` is the one method an old component will still +/// answer), and the next two decisions both depend on it: a protocol mismatch +/// must be blocked on the onboarding page, and a version mismatch must light +/// up "update privileged component". Getting the parsing wrong fails silently +/// -- either an old component is treated as current (the user keeps running the +/// pre-upgrade library) or a useless update prompt appears every day that never +/// goes away when clicked. Neither produces any error; only tests can guard +/// this. final class HelperConstantsTests: XCTestCase { - // MARK: - 编解码 + // MARK: - Encode/decode func testEncodeDecodeRoundTrip() { let encoded = HelperConstants.encodeVersion("TetherKit 0.1.4 (C++23, macOS 13.3+)") @@ -22,7 +26,8 @@ final class HelperConstantsTests: XCTestCase { XCTAssertEqual(version, "TetherKit 0.1.4 (C++23, macOS 13.3+)") } - /// 旧组件回的串里没有分隔符 —— 这正是要识别出来的那种组件,修订号记 0。 + /// A string returned by an old component has no separator -- exactly the kind + /// of component that must be identified, recorded as revision 0. func testDecodeLegacyVersionWithoutRevision() { let (revision, version) = HelperConstants.decodeVersion("TetherKit 0.1.2 (C++23, macOS 13.3+)") @@ -30,25 +35,28 @@ final class HelperConstantsTests: XCTestCase { XCTAssertEqual(revision, 0) XCTAssertEqual(version, "TetherKit 0.1.2 (C++23, macOS 13.3+)") XCTAssertNotEqual(revision, HelperConstants.protocolRevision, - "0 必须与任何真实修订号都不同,否则旧组件会被当成匹配的") + "0 must differ from every real revision, otherwise an old component would be treated as matching") } - // MARK: - 版本号提取 + // MARK: - Version number extraction func testSemanticVersionFromLibraryVersionString() { XCTAssertEqual( HelperConstants.semanticVersion(of: "TetherKit 0.1.4 (C++23, macOS 13.3+)"), "0.1.4") } - /// 串里另外两个数字(C++ 标准、最低 macOS 版本)都不能被当成版本号: - /// 前者没有点,后者在版本号之后。 + /// The other two numbers in the string (C++ standard, minimum macOS version) + /// must not be taken as the version number: the former has no dot, the latter + /// comes after the version number. func testSemanticVersionIgnoresBuildConfigurationNumbers() { let text = "TetherKit 1.0.0 (C++23, macOS 13.3+)" XCTAssertEqual(HelperConstants.semanticVersion(of: text), "1.0.0") } - /// 只有构建配置不同(同一个版本换了编译选项重建)**不算**版本不一致 —— - /// 这就是不直接比整串的原因:否则会冒出一个点了也不会消失的假警报。 + /// A difference in build configuration only (the same version rebuilt with + /// different compile options) does not count as a version mismatch -- this is + /// why the whole string is not compared directly: otherwise a spurious + /// warning would appear that never goes away when clicked. func testSemanticVersionEqualAcrossBuildConfigurations() { let installed = HelperConstants.semanticVersion(of: "TetherKit 0.1.4 (C++23, macOS 13.3+)") let bundled = HelperConstants.semanticVersion(of: "TetherKit 0.1.4 (C++26, macOS 15.0+)") @@ -65,16 +73,18 @@ final class HelperConstantsTests: XCTestCase { XCTAssertEqual(bundled, "0.1.4") } - /// 解析不出来时退回整串:宁可误报(弹一个多余的更新提示),也不要漏报 - /// (用户一直跑着旧组件而毫不知情)。 + /// Falls back to the whole string when nothing can be extracted: better a + /// false positive (a spurious update prompt) than a missed one (the user keeps + /// running an old component without knowing it). func testSemanticVersionFallsBackToWholeString() { XCTAssertEqual(HelperConstants.semanticVersion(of: " TetherKit dev "), "TetherKit dev") XCTAssertNotEqual(HelperConstants.semanticVersion(of: "TetherKit dev-a"), HelperConstants.semanticVersion(of: "TetherKit dev-b")) } - /// 装好之后自己和自己比必须一致 —— 这条一旦红了,界面上会出现一个 - /// 「组件该更新了」但更新完还在的死循环提示。 + /// Comparing a version to itself after installation must match -- if this + /// turns red, the UI will show a "component needs updating" prompt that stays + /// in an endless loop even after updating. func testSemanticVersionIsStableForSameInput() { let text = "TetherKit 0.1.4 (C++23, macOS 13.3+)" XCTAssertEqual(HelperConstants.semanticVersion(of: text), diff --git a/include/tetherkit/capi/.clang-tidy b/include/tetherkit/capi/.clang-tidy index e8db6ca..ef64954 100644 --- a/include/tetherkit/capi/.clang-tidy +++ b/include/tetherkit/capi/.clang-tidy @@ -1,11 +1,13 @@ -# C ABI 层的检查豁免 +# Check exemptions for the C ABI layer # -# 这一层是**给 C / Swift 用的**,命名必须遵守 C 的惯例(`tk_snake_case`), -# 而不是项目内部 C++ 代码的 CamelCase。硬套上层规则只会得到一堆无法修的告警, -# 所以在这个目录里整组关掉命名检查。 +# This layer is **for C and Swift callers**, so the names must follow the C +# convention (`tk_snake_case`) rather than the CamelCase of the project's +# internal C++ code. Forcing the upper-level rules on it would only produce a +# pile of warnings nobody could fix, so the whole naming group is off here. # -# 同样地,C ABI 只能用 POD 与裸指针,因此关掉那些「改用 std::array / span / -# 引用」之类在 extern "C" 边界上根本不适用的建议。 +# Likewise, a C ABI can only use PODs and raw pointers, so the advice to +# "use std::array / span / references instead" is turned off -- it simply does +# not apply at an extern "C" boundary. InheritParentConfig: true Checks: > diff --git a/include/tetherkit/capi/tetherkit_c.h b/include/tetherkit/capi/tetherkit_c.h index 5ea251f..214ed3e 100644 --- a/include/tetherkit/capi/tetherkit_c.h +++ b/include/tetherkit/capi/tetherkit_c.h @@ -1,36 +1,52 @@ -// TetherKit 的 C ABI —— 全项目**唯一**的 `extern "C"` 边界。 +// The C ABI of TetherKit -- the **only** extern "C" boundary in the whole +// project. // -// 存在理由:Swift 的 C++ 互操作吞不下本项目的 C++23 接口(`std::expected`、 -// `std::span`、抽象基类、移动语义),所以 GUI 不能直接调 C++ 层。这一层把那些 -// 类型翻译成「纯 POD + 不透明句柄」,让 Swift、Objective-C、乃至其它语言都能用。 +// Why it exists: Swift's C++ interop cannot swallow this project's C++23 +// interfaces (std::expected, std::span, abstract base classes, move +// semantics), so the GUI cannot call the C++ layer directly. This layer +// translates those types into "plain POD plus opaque handles" so that Swift, +// Objective-C, and even other languages can use them. // -// ★ 设计约定(照着写就不会出错)★ +// Design conventions (follow them and you cannot go wrong) // -// 1. **不跨边界传所有权。** 所有输出都写进调用方提供的定长结构体,库内部不 -// 分配需要调用方释放的内存。唯一的例外是 `tk_session_t` 这个不透明句柄, -// 它由 `tk_session_create` 创建、`tk_session_destroy` 销毁,配对明确。 +// 1. **No ownership crosses the boundary.** Every output is written into a +// fixed-length struct supplied by the caller, and the library never +// allocates memory that the caller would have to free. The only +// exception is the opaque handle tk_session_t, which is created by +// `tk_session_create` and destroyed by `tk_session_destroy` -- a clear +// pairing. // -// 2. **不用回调,用事件队列。** 底层的观察者回调会从 libusb 事件线程与控制 -// 线程上来,Swift 侧要跨线程 marshal;更要命的是重入 —— 在回调里调停机会 -// 自等死锁(`Stop()` 要 join 控制线程)。所以一律改成「库内排队、宿主轮询」。 -// 日志同理,见 `tk_drain_logs`。 +// 2. **No callbacks, an event queue instead.** The observer callbacks +// underneath arrive on the libusb event thread and on the control +// thread, and the Swift side would have to marshal across threads; +// worse is reentrancy -- calling stop from inside a callback deadlocks +// on itself (Stop() joins the control thread). So everything became +// "the library queues, the host polls". The same goes for the logs, see +// `tk_drain_logs`. // -// 3. **所有函数返回 `tk_result_t`(0 为成功、负值为失败)**,失败原因写进 -// 可选的 `tk_error_t*`。传 NULL 表示不关心原因。不用 errno、不用线程局部 -// 的 last-error —— 那两种在多线程宿主里都容易读错。 +// 3. **Every function returns `tk_result_t` (0 is success, negative is +// failure)**, and the reason for the failure goes into the optional +// `tk_error_t*`. Passing NULL means the reason does not matter. No +// errno, no thread-local last-error -- both are easy to misread in a +// multithreaded host. // -// 4. **字符串一律是定长 UTF-8 缓冲、以 NUL 结尾**,容量不足时截断而非失败。 -// 截断只影响展示,不影响功能,比让调用方处理两次调用的长度协商简单得多。 +// 4. **Strings are always fixed-length NUL-terminated UTF-8 buffers**, and +// they are truncated rather than failing when the capacity is too +// small. Truncation only affects the display, not the function, which +// is far simpler than making the caller negotiate lengths across two +// calls. // -// ★ 权限(哪些函数需要 root)★ +// Permissions (which functions need root) // -// 不需要 root:`tk_version`、`tk_check_environment`、`tk_list_devices`、 -// `tk_drain_logs`、`tk_net_query` -// 需要 root :`tk_session_*`(要建 feth、开 /dev/bpf*)、`tk_net_apply`、 -// `tk_net_clear`、`tk_cleanup_orphan_interfaces` +// No root required: `tk_version`, `tk_check_environment`, `tk_list_devices`, +// `tk_drain_logs`, `tk_net_query` +// Root required : `tk_session_*` (it creates the feth and opens +// /dev/bpf*), `tk_net_apply`, `tk_net_clear`, +// `tk_cleanup_orphan_interfaces` // -// GUI 的做法是:App 本体只调不需要 root 的那些,需要 root 的交给 -// `tetherkit-helper`(由 launchd 以 root 拉起)。详见 docs/GUI-ARCHITECTURE.md。 +// What the GUI does: the app itself only calls the functions that need no +// root, and hands the ones that do to `tetherkit-helper` (launched by +// launchd as root). See docs/GUI-ARCHITECTURE.md. #ifndef TETHERKIT_CAPI_TETHERKIT_C_H_ #define TETHERKIT_CAPI_TETHERKIT_C_H_ @@ -49,50 +65,62 @@ extern "C" { #endif // ============================================================================= -// 容量常量 +// Capacity constants // -// 之所以把容量写死在头文件里而不是让调用方协商长度:这些结构体会被 Swift 直接 -// 映射成 struct 并按值拷贝,定长才能省掉全部的内存管理。数值都留了足够余量。 +// The capacities are hardcoded in the header instead of having the caller +// negotiate lengths: these structs get mapped directly to a Swift struct and +// copied by value, and a fixed length saves all of the memory management. The +// numbers all leave generous headroom. // ============================================================================= -/// 错误消息缓冲容量。底层错误串会带多行「接下来该怎么办」的提示,1 KiB 够用。 +/// Error message buffer capacity. The underlying error strings carry a +/// multi-line "what to do next" hint, so 1 KiB is enough. #define TK_ERROR_MESSAGE_CAPACITY 1024 -/// 网卡名容量。对齐内核的 IFNAMSIZ(16,含终止符)。 +/// Interface name capacity. Matches the kernel's IFNAMSIZ (16, terminator +/// included). #define TK_INTERFACE_NAME_CAPACITY 16 -/// USB 字符串描述符(厂商名 / 产品名 / 序列号)容量。 +/// USB string descriptor capacity (manufacturer name / product name / serial +/// number). #define TK_USB_STRING_CAPACITY 128 -/// IP 地址文本容量。对齐 INET6_ADDRSTRLEN(46)。 +/// IP address text capacity. Matches INET6_ADDRSTRLEN (46). #define TK_ADDRESS_CAPACITY 46 -/// 一次配置最多接受几个 DNS 服务器。 +/// How many DNS servers one configuration accepts at most. #define TK_DNS_MAX 4 -/// 单条日志 / 事件的文本容量。 +/// Text capacity of a single log line / event. #define TK_TEXT_CAPACITY 512 -/// 线程名容量,对齐 pthread_setname_np 的实际上限。 +/// Thread name capacity, matching the real limit of pthread_setname_np. #define TK_THREAD_NAME_CAPACITY 16 // ============================================================================= -// 结果码与错误 +// Result codes and errors // ============================================================================= typedef enum tk_result { - TK_OK = 0, ///< 成功。 - TK_ERR_INVALID_ARGUMENT = -1, ///< 参数非法(空指针、越界枚举、格式错误的地址)。 - TK_ERR_INVALID_STATE = -2, ///< 当前状态下不允许该操作(如重复 start)。 - TK_ERR_FAILED = -3, ///< 底层操作失败,细节见 tk_error_t。 - TK_ERR_PERMISSION = -4, ///< 需要 root 但当前不是。 - TK_ERR_NOT_FOUND = -5, ///< 目标不存在(没有设备、没有该网卡)。 + TK_OK = 0, ///< Success. + TK_ERR_INVALID_ARGUMENT = -1, ///< Invalid argument (null pointer, out-of-range + ///< enum, malformed address). + TK_ERR_INVALID_STATE = -2, ///< The operation is not allowed in the current + ///< state (e.g. a repeated start). + TK_ERR_FAILED = -3, ///< The underlying operation failed; the + ///< details are in tk_error_t. + TK_ERR_PERMISSION = -4, ///< Root is required but the process is not root. + TK_ERR_NOT_FOUND = -5, ///< The target does not exist (no device, no + ///< such interface). } tk_result_t; -/// 错误码的来源域,决定 `code` 字段怎么解读。与 C++ 侧 `tetherkit::ErrorDomain` -/// 一一对应。 +/// The domain an error code comes from; it decides how the `code` field is to +/// be interpreted. It corresponds one to one with `tetherkit::ErrorDomain` on +/// the C++ side. typedef enum tk_error_domain { - TK_ERROR_DOMAIN_GENERIC = 0, ///< 纯逻辑错误,`code` 无意义。 - TK_ERROR_DOMAIN_ERRNO = 1, ///< POSIX errno。 - TK_ERROR_DOMAIN_LIBUSB = 2, ///< libusb 的 enum libusb_error(负值)。 - TK_ERROR_DOMAIN_RNDIS = 3, ///< RNDIS_STATUS_*。 + TK_ERROR_DOMAIN_GENERIC = 0, ///< A purely logical error; `code` is + ///< meaningless. + TK_ERROR_DOMAIN_ERRNO = 1, ///< A POSIX errno. + TK_ERROR_DOMAIN_LIBUSB = 2, ///< libusb's enum libusb_error (negative). + TK_ERROR_DOMAIN_RNDIS = 3, ///< RNDIS_STATUS_*. } tk_error_domain_t; -/// 一次失败的完整描述。`message` 已经是可以直接展示给用户的中文。 +/// The full description of one failure. `message` is already a text that can +/// be shown to the user as is. typedef struct tk_error { int32_t domain; ///< tk_error_domain_t int64_t code; @@ -145,88 +173,109 @@ TK_API int32_t tk_get_language(void); TK_API int32_t tk_detect_system_language(void); // ============================================================================= -// 版本与环境预检(均不需要 root) +// Version and environment preflight (neither needs root) // ============================================================================= typedef struct tk_version_info { uint16_t major; uint16_t minor; uint16_t patch; - /// 形如 "TetherKit 0.1.1 (C++23, macOS 13.3+)"。 + /// Of the form "TetherKit 0.1.1 (C++23, macOS 13.3+)". char text[64]; - /// 构建配置描述,形如 "RelWithDebInfo, AppleClang 21.0.0, ..."。 + /// The build configuration description, of the form "RelWithDebInfo, + /// AppleClang 21.0.0, ...". char build[192]; - /// libusb 版本串。 + /// The libusb version string. char libusb[64]; } tk_version_info_t; TK_API void tk_version(tk_version_info_t* out_version); -/// 环境预检结果。GUI 在启动前展示它,把「为什么跑不起来」提前说清楚。 +/// The result of the environment preflight. The GUI shows it before startup +/// to make "why it will not run" clear up front. typedef struct tk_environment { - /// 当前进程是否以 root 运行。 + /// Whether the current process runs as root. bool is_root; - /// feth 的创建期 sysctl 是否都处于我们要求的值。 + /// Whether the feth creation-time sysctls all hold the values we require. /// - /// 这批开关(hwcsum / fcs / tso_support / lro / trailer_length / - /// separate_frame_header)是**创建期快照**,创建后再改无效,所以必须提前检查。 + /// These switches (hwcsum / fcs / tso_support / lro / trailer_length / + /// separate_frame_header) are a **creation-time snapshot**, changing them + /// after creation has no effect, so they have to be checked up front. bool sysctls_ok; - /// sysctl 不合格时的具体说明;合格时为空串。 + /// The specifics when the sysctls do not qualify; an empty string when + /// they do. char sysctl_detail[TK_ERROR_MESSAGE_CAPACITY]; - /// feth 支持的 MTU 上限(sysctl net.link.fake.max_mtu)。查询失败时为 0。 + /// The MTU ceiling feth supports (sysctl net.link.fake.max_mtu). 0 when + /// the query failed. uint32_t feth_max_mtu; } tk_environment_t; -/// 采集环境信息。**本函数永远返回 TK_OK** —— 「环境不合格」不是调用失败, -/// 而是要展示给用户的结果。 +/// Collects the environment information. **This function always returns +/// TK_OK** -- "the environment does not qualify" is not a call failure but a +/// result to show to the user. TK_API tk_result_t tk_check_environment(tk_environment_t* out_environment); // ============================================================================= -// 设备枚举(不需要 root) +// Device enumeration (no root required) // ============================================================================= -/// 一个被识别为 RNDIS 的候选设备。 +/// A candidate device recognized as RNDIS. typedef struct tk_device_info { uint16_t vendor_id; uint16_t product_id; uint8_t bus_number; uint8_t device_address; - /// 通信类接口(承载控制通道 + 中断通知)。 + /// The communication-class interface (carries the control channel and the + /// interrupt notifications). uint8_t control_interface; - /// 数据类接口(承载 bulk IN/OUT)。 + /// The data-class interface (carries bulk IN/OUT). uint8_t data_interface; - /// 匹配到的接口签名,用于说明「按哪种 RNDIS 形态识别的」。 + /// The matched interface signature, used to say "which RNDIS shape this + /// was recognized as". uint8_t interface_class; uint8_t interface_subclass; uint8_t interface_protocol; - /// 是否走了 Android quirk 的兜底路径(CDC Union 描述符不可信时)。 + /// Whether the Android quirk fallback path was taken (when the CDC Union + /// descriptor cannot be trusted). bool used_android_quirk; - /// 以下三个字符串描述符是**尽力而为**的:读它们需要 libusb_open, - /// 而设备可能已被本机的另一个进程独占。本次读不到时会回填**本进程内**上次 - /// 成功读到的值(设备没变,名字不该因为被占用就消失);连上次的值都没有 - /// 才是空串,此时 GUI 应回落到展示 `description`(VID:PID 形式)。 + /// The three string descriptors below are **best effort**: reading them + /// requires libusb_open, and the device may already be held exclusively by + /// another process on this machine. When this read fails, the value + /// **last read successfully in this process** is filled back in (the device + /// has not changed, so the name should not vanish just because it is busy); + /// only when there is no previous value either is it an empty string, and + /// then the GUI should fall back to showing `description` (the VID:PID + /// form). char manufacturer[TK_USB_STRING_CAPACITY]; char product[TK_USB_STRING_CAPACITY]; char serial[TK_USB_STRING_CAPACITY]; - /// 形如 "Bus 020 Device 003: 18d1:4ee4",任何情况下都可用。 + /// Of the form "Bus 020 Device 003: 18d1:4ee4", available in every case. char description[64]; } tk_device_info_t; -/// 枚举当前连接的 RNDIS 设备。 +/// Enumerates the RNDIS devices currently connected. /// -/// @param out_devices 调用方提供的数组,可为 NULL(此时只统计数量)。 -/// @param capacity 数组容量。实际找到的设备多于容量时只填前 capacity 个。 -/// @param out_count 写入实际找到的设备总数(可能大于 capacity)。 -/// @param read_strings 是否尝试读取厂商名 / 产品名 / 序列号。这需要 -/// libusb_open,在设备已被占用时会失败(不致命)。 -/// 会话运行期间刷新列表时建议传 false —— 无论跳过还是 -/// 读失败,都会回填本进程内上次成功读到的值,名字不会 -/// 因此变成空串。 +/// @param out_devices The array supplied by the caller; may be NULL (in +/// which case only the count is computed). +/// @param capacity The capacity of the array. When more devices are +/// found than fit, only the first `capacity` ones are +/// filled in. +/// @param out_count Receives the total number of devices found (it may +/// be greater than capacity). +/// @param read_strings Whether to try to read the manufacturer name / +/// product name / serial number. This requires +/// libusb_open and fails when the device is already +/// held (not fatal). +/// When refreshing the list while a session is running, +/// false is recommended -- whether they are skipped or +/// the read fails, the values last read successfully in +/// this process are filled back in, so the names will +/// not turn into empty strings because of it. TK_API tk_result_t tk_list_devices(tk_device_info_t* out_devices, size_t capacity, size_t* out_count, bool read_strings, tk_error_t* out_error); // ============================================================================= -// 日志 +// Logs // ============================================================================= typedef enum tk_log_level { @@ -240,8 +289,8 @@ typedef enum tk_log_level { typedef struct tk_log_record { int32_t level; ///< tk_log_level_t - /// 墙上时间(自 Unix 纪元的纳秒)。用墙上时间而非单调时间:日志要能和系统 - /// 日志对齐。 + /// Wall clock time (nanoseconds since the Unix epoch). Wall clock rather + /// than monotonic time: the logs have to line up with the system logs. int64_t wall_nanos; char thread[TK_THREAD_NAME_CAPACITY]; char message[TK_TEXT_CAPACITY]; @@ -249,87 +298,104 @@ typedef struct tk_log_record { TK_API void tk_set_log_level(int32_t level); -/// 打开 / 关闭日志捕获。 +/// Turns log capture on / off. /// -/// 默认**关闭** —— 命令行程序不需要,开着白白多一份拷贝。GUI 宿主在启动时调用 -/// `tk_enable_log_capture(true)`,之后周期性 `tk_drain_logs` 取走。 +/// **Off** by default -- a command line program does not need it, and having +/// it on only costs an extra copy. A GUI host calls +/// `tk_enable_log_capture(true)` at startup and then periodically takes the +/// records away with `tk_drain_logs`. TK_API void tk_enable_log_capture(bool enabled); -/// 取走已缓冲的日志行(先进先出),返回实际取走的条数。 +/// Takes the buffered log lines away (first in, first out) and returns how +/// many were actually taken. /// -/// 缓冲区是定长环形队列,写满时丢弃**最旧**的记录 —— 宿主卡住时保留最新现场 -/// 比保留开头更有用。被丢弃的条数通过 `out_dropped` 汇报,GUI 可据此提示 -/// 「日志有缺口」。 +/// The buffer is a fixed-length ring queue; when it fills up the **oldest** +/// records are dropped -- when the host stalls, keeping the newest scene is +/// more useful than keeping the beginning. The number of dropped records is +/// reported through `out_dropped`, so the GUI can hint "there is a gap in the +/// logs". TK_API size_t tk_drain_logs(tk_log_record_t* out_records, size_t capacity, uint64_t* out_dropped); // ============================================================================= -// 会话(需要 root) +// Sessions (root required) // ============================================================================= -/// RNDIS 主机侧状态,与 C++ 侧 `tetherkit::rndis::State` 一一对应。 +/// The host-side RNDIS state, corresponding one to one with +/// `tetherkit::rndis::State` on the C++ side. typedef enum tk_rndis_state { TK_RNDIS_UNINITIALIZED = 0, TK_RNDIS_INITIALIZING = 1, TK_RNDIS_INITIALIZED = 2, - TK_RNDIS_DATA_INITIALIZED = 3, ///< 数据开始流动。 + TK_RNDIS_DATA_INITIALIZED = 3, ///< The data starts flowing. TK_RNDIS_HALTING = 4, } tk_rndis_state_t; -/// 会话的生命周期状态。这是 GUI 主界面直接绑定的那个状态。 +/// The lifecycle state of a session. This is the state the GUI main window +/// binds to directly. typedef enum tk_run_state { - TK_RUN_IDLE = 0, ///< 已创建,尚未 start。 - TK_RUN_STARTING = 1, ///< 正在走启动序列(枚举 → 握手 → 建网卡 → 开桥接)。 - TK_RUN_RUNNING = 2, ///< 数据路径已跑起来。 + TK_RUN_IDLE = 0, ///< Created, not yet started. + TK_RUN_STARTING = 1, ///< Start sequence: enumerate, handshake, create the + ///< interface, open the bridge. + TK_RUN_RUNNING = 2, ///< The data paths are up. TK_RUN_STOPPING = 3, TK_RUN_STOPPED = 4, - TK_RUN_FAILED = 5, ///< 启动失败或运行中遇到不可恢复错误,原因见 status.fatal。 + TK_RUN_FAILED = 5, ///< The start failed or an unrecoverable error + ///< occurred; the reason is in status.fatal. } tk_run_state_t; -/// 会话配置。用 `tk_session_config_init` 填好默认值后再改需要改的字段 —— -/// 直接 `{0}` 初始化会得到一堆无效的 0。 +/// Session configuration. Fill in the defaults with `tk_session_config_init` +/// and then change the fields you care about -- a plain `{0}` initialization +/// leaves a pile of invalid zeros. typedef struct tk_session_config { - /// 设备筛选。全为 0 表示用第一个找到的 RNDIS 设备。 + /// The device filter. All-zero means use the first RNDIS device found. uint16_t vendor_id; uint16_t product_id; - /// 只匹配指定总线上的指定地址(两者必须同时给出)。用于区分两台同型号设备。 + /// Match only the given address on the given bus (both must be given). + /// Used to tell two devices of the same model apart. uint8_t bus_number; uint8_t device_address; - /// 希望使用的 MTU。设备装不下时会被协商下调;上限受 feth 的 - /// sysctl net.link.fake.max_mtu 约束。 + /// The desired MTU. It is negotiated down when the device cannot take it; + /// the ceiling is the feth sysctl net.link.fake.max_mtu. uint32_t mtu; - /// 是否把系统侧网卡的 MAC 设为设备汇报的地址。默认开。 + /// Whether to set the MAC of the system-side interface to the + /// device-reported address. On by default. bool adopt_device_mac; - /// 并发在飞的 bulk IN / OUT 传输数。 + /// How many bulk IN / OUT transfers are in flight concurrently. uint32_t rx_transfer_count; uint32_t tx_transfer_count; - /// 每个 bulk IN 缓冲的 KiB 数。 + /// The KiB size of each bulk IN buffer. uint32_t rx_transfer_kib; - /// 在 INITIALIZE_MSG 里宣称的 MaxTransferSize(KiB)。这是让设备聚合多包的 - /// 唯一手段,也是吞吐的主要杠杆。 + /// The MaxTransferSize (KiB) advertised in INITIALIZE_MSG. This is the + /// only way to make the device aggregate multiple packets, and it is the + /// main lever on throughput. uint32_t max_transfer_kib; - /// BPF 内核抓包缓冲的 KiB 数。 + /// The KiB size of the kernel BPF capture buffer. uint32_t bpf_buffer_kib; } tk_session_config_t; -/// 用推荐默认值填充配置。 +/// Fills the configuration with the recommended defaults. TK_API void tk_session_config_init(tk_session_config_t* out_config); -/// 会话事件。 +/// Session events. typedef enum tk_event_kind { - /// RNDIS 状态迁移。`a` = 迁移前状态,`b` = 迁移后状态(均为 tk_rndis_state_t)。 + /// An RNDIS state transition. `a` = the state before the transition, + /// `b` = the state after it (both tk_rndis_state_t). TK_EVENT_RNDIS_STATE = 0, - /// 协商完成。`a` = 最终 MTU,`b` = 链路速率(Mbps)。 + /// Negotiation complete. `a` = the final MTU, `b` = the link speed (Mbps). TK_EVENT_NEGOTIATED = 1, - /// 链路 up / down。`a` = 1 表示已连接。 + /// Link up / down. `a` = 1 means connected. TK_EVENT_LINK = 2, - /// 设备软复位。`a` = 1 表示寻址信息丢失、已重放。 + /// Device soft reset. `a` = 1 means the addressing information was lost + /// and replayed. TK_EVENT_DEVICE_RESET = 3, - /// 不可恢复错误,`text` 为原因。会话随后进入 TK_RUN_FAILED。 + /// An unrecoverable error; `text` is the reason. The session then enters + /// TK_RUN_FAILED. TK_EVENT_FATAL = 4, - /// 会话生命周期状态变化。`a` = 迁移前,`b` = 迁移后(均为 tk_run_state_t)。 + /// A lifecycle state change of the session. `a` = before the transition, + /// `b` = after it (both tk_run_state_t). TK_EVENT_RUN_STATE = 5, } tk_event_kind_t; @@ -341,131 +407,162 @@ typedef struct tk_event { char text[TK_TEXT_CAPACITY]; } tk_event_t; -/// 数据路径与链路的完整快照。GUI 定时(建议 500 ms~1 s)拉取它刷新界面。 +/// A complete snapshot of the data paths and the link. The GUI pulls it +/// periodically (500 ms to 1 s is recommended) to refresh the UI. typedef struct tk_session_status { int32_t run_state; ///< tk_run_state_t int32_t rndis_state; ///< tk_rndis_state_t bool link_up; - bool paused; ///< 数据搬运是否处于暂停(链路 down 或设备复位期间)。 + bool paused; ///< Whether the data movement is paused (link down or during a + ///< device reset). - /// 系统侧网卡名(主机在这张上配 IP)。未创建时为空串。 + /// The system-side interface name (the host configures the IP on this + /// one). An empty string until it is created. char system_interface[TK_INTERFACE_NAME_CAPACITY]; - /// 驱动侧网卡名(TetherKit 的 BPF 挂在这张上)。仅用于排障展示。 + /// The driver-side interface name (TetherKit's BPF attaches to this one). + /// For diagnostics display only. char driver_interface[TK_INTERFACE_NAME_CAPACITY]; - /// 设备汇报的永久 MAC。未协商时全 0。 + /// The permanent MAC the device reports. All zero when not negotiated. uint8_t device_mac[6]; - /// 协商后的最终 MTU。 + /// The final MTU after negotiation. uint32_t mtu; - /// 链路速率(Mbps)。 + /// The link speed (Mbps). uint32_t link_speed_mbps; - /// 设备厂商描述串(来自 OID_GEN_VENDOR_DESCRIPTION)。 + /// The device vendor description string (from OID_GEN_VENDOR_DESCRIPTION). char vendor_description[64]; - /// 正在使用的设备,形如 "Bus 020 Device 003: 18d1:4ee4"。 + /// The device in use, of the form "Bus 020 Device 003: 18d1:4ee4". char device_description[64]; - /// 累计计数器(RX = 设备 → 主机,TX = 主机 → 设备)。 - /// 速率由 GUI 自己按两次快照做差算,库不做窗口平滑。 + /// The cumulative counters (RX = device -> host, TX = host -> device). + /// The GUI computes the rates itself by differencing two snapshots; the + /// library does no window smoothing. uint64_t rx_frames; uint64_t rx_bytes; uint64_t rx_dropped; uint64_t tx_frames; uint64_t tx_bytes; uint64_t tx_dropped; - /// RX 队列当前深度,用于判断哪一侧是瓶颈。 + /// The current RX queue depth, used to tell which side is the bottleneck. uint64_t rx_queue_depth; - /// 内核 BPF 侧累计丢包(bs_drop)。 + /// The cumulative frame drops on the kernel BPF side (bs_drop). uint64_t link_kernel_drops; - /// 因 TX 传输池没有空闲槽位而产生的背压次数。 + /// How many times backpressure occurred because the TX transfer pool had + /// no free slot. uint64_t tx_backpressure; - /// 采样时刻的单调纳秒计数。GUI 用它算速率,别用自己的时钟 —— - /// 两次拉取之间的真实间隔可能被调度拉长。 + /// The monotonic nanosecond count at the sampling moment. The GUI uses it + /// to compute the rates -- do not use its own clock, the real interval + /// between two pulls can be stretched by scheduling. int64_t monotonic_nanos; - /// run_state == TK_RUN_FAILED 时的原因;否则为空串。 + /// The reason when run_state == TK_RUN_FAILED; an empty string otherwise. char fatal[TK_ERROR_MESSAGE_CAPACITY]; } tk_session_status_t; -/// 不透明会话句柄。 +/// An opaque session handle. typedef struct tk_session tk_session_t; -/// 创建会话。**不做任何 I/O**,只是记下配置。 +/// Creates a session. **Does no I/O at all**, it only records the +/// configuration. TK_API tk_session_t* tk_session_create(const tk_session_config_t* config, tk_error_t* out_error); -/// 启动会话。**非阻塞**:内部起一条控制线程跑启动序列与保活循环,本函数立刻返回。 +/// Starts the session. **Non-blocking**: internally it starts one control +/// thread that runs the start sequence and the keep-alive loop, and this +/// function returns immediately. /// -/// 返回 TK_OK 只表示「启动请求已受理」,真正的成败要通过 `tk_session_status` -/// 的 run_state 与事件队列观察。这样设计是因为启动序列包含 USB 握手,在慢设备上 -/// 可能要几百毫秒到数秒,阻塞 GUI 主线程不可接受。 +/// Returning TK_OK only means "the start request has been accepted"; the real +/// outcome has to be observed through the run_state of `tk_session_status` and +/// the event queue. The reason for this design is that the start sequence +/// contains a USB handshake, which on a slow device can take hundreds of +/// milliseconds to seconds, and blocking the GUI main thread is +/// unacceptable. /// -/// 控制通道必须跑在**非 libusb 事件线程**上(同步 API 在事件线程上返回 -/// LIBUSB_ERROR_BUSY),库内部保证了这一点,调用方无需关心线程。 +/// The control channel must run on a **non-libusb event thread** (the +/// synchronous API returns LIBUSB_ERROR_BUSY on the event thread); the +/// library guarantees this internally, so the caller does not have to care +/// about threads. TK_API tk_result_t tk_session_start(tk_session_t* session, tk_error_t* out_error); -/// 请求停机并等待控制线程退出。幂等。 +/// Requests the shutdown and waits for the control thread to exit. +/// Idempotent. /// -/// ⚠️ **不能从事件轮询的回调里调用** —— 本函数会 join 控制线程。C ABI 这一层 -/// 没有回调,所以只要调用方不在自己的事件处理里持有会引发重入的锁就没问题。 +/// **Must not be called from inside an event polling callback** -- this +/// function joins the control thread. The C ABI layer has no callbacks, so +/// as long as the caller does not hold a lock in its own event handling that +/// would cause reentrancy, it is fine. TK_API tk_result_t tk_session_stop(tk_session_t* session); -/// 停机并销毁。传 NULL 是空操作。 +/// Stops and destroys. Passing NULL is a no-op. TK_API void tk_session_destroy(tk_session_t* session); -/// 取一份状态快照。任意线程可调。 +/// Takes a status snapshot. Callable from any thread. TK_API tk_result_t tk_session_status_get(tk_session_t* session, tk_session_status_t* out_status); -/// 取走已排队的事件(先进先出),返回实际取走的条数。 +/// Takes the queued events away (first in, first out) and returns how many +/// were actually taken. /// -/// 与日志一样是定长环形队列,写满丢最旧。GUI 漏掉几条状态迁移事件不影响正确性 -/// —— 权威状态始终是 `tk_session_status_get` 的快照,事件只是用来做动画与提示。 +/// Like the logs it is a fixed-length ring queue; when it fills up the oldest +/// are dropped. Missing a few state transition events does not affect +/// correctness -- the authoritative state is always the snapshot from +/// `tk_session_status_get`; the events are only for animations and hints. TK_API size_t tk_session_poll_events(tk_session_t* session, tk_event_t* out_events, size_t capacity); // ============================================================================= -// 网卡 IP 配置(需要 root) +// Interface IP configuration (root required) // ============================================================================= -/// 上网方式。 +/// The network access mode. typedef enum tk_ip_mode { - /// 交给系统的 IPConfiguration 跑 DHCP。它会自动完成四件事:拿租约、配 - /// scoped DNS、装 scoped 默认路由、把服务发布到动态存储。 + /// Hand it to the system's IPConfiguration to run DHCP. It takes care of + /// four things: getting the lease, setting up the scoped DNS, installing + /// the scoped default route, and publishing the service into the dynamic + /// store. TK_IP_MODE_DHCP = 0, - /// 静态 IP。地址仍然经 IPConfiguration 下发(而不是裸 SIOCAIFADDR), - /// 这样才能得到一个「经正规路径注册」的服务 —— 手工往动态存储写条目是 - /// **不会被 IPMonitor 采纳**的,这一点已实测确认。 + /// A static IP. The address is still delivered through IPConfiguration + /// (rather than a bare SIOCAIFADDR), because only that way does one get a + /// service "registered through the proper path" -- writing entries into + /// the dynamic store by hand is **not adopted by IPMonitor**, which has + /// been measured and confirmed. TK_IP_MODE_MANUAL = 1, - /// 撤销配置(等价于 `ipconfig set NONE`)。 + /// Revokes the configuration (equivalent to `ipconfig set NONE`). TK_IP_MODE_NONE = 2, } tk_ip_mode_t; typedef struct tk_ip_config { int32_t mode; ///< tk_ip_mode_t - /// 以下四项仅在 mode == TK_IP_MODE_MANUAL 时使用。 + /// The four items below are only used when mode == TK_IP_MODE_MANUAL. char address[TK_ADDRESS_CAPACITY]; char netmask[TK_ADDRESS_CAPACITY]; - /// 路由器(网关)。留空表示不配任何默认路由。 + /// The router (gateway). An empty string means no default route is + /// configured at all. char router[TK_ADDRESS_CAPACITY]; char dns[TK_DNS_MAX][TK_ADDRESS_CAPACITY]; int32_t dns_count; - /// 是否把**全局**默认路由也指向本网卡。 + /// Whether to point the **global** default route at this interface as well. /// - /// 不开时只会有一条 scoped 默认路由(`RTF_IFSCOPE`),绑定到本接口的流量走它, - /// 未绑定的流量仍走系统主服务。只有在同时存在更高优先级的连通服务(Wi-Fi、 - /// VPN)时才需要开 —— USB 网络共享的典型场景恰恰是没有别的网络可用, - /// 那时本网卡自然就是主服务,这个开关不用动。 + /// When this is off there is only a scoped default route (`RTF_IFSCOPE`); + /// the traffic bound to this interface takes it, while unbound traffic + /// still takes the system's primary service. It only needs to be on when a + /// higher-priority connected service (Wi-Fi, VPN) exists at the same time + /// -- and the typical scenario of USB network sharing is precisely that no + /// other network is available, so this interface naturally is the primary + /// service and the switch does not have to be touched. bool set_default_route; } tk_ip_config_t; -/// 用默认值(DHCP)填充配置。 +/// Fills the configuration with the defaults (DHCP). TK_API void tk_ip_config_init(tk_ip_config_t* out_config); -/// 网卡当前**真实生效**的 IP 状态。 +/// The IP state the interface **really has in effect** right now. /// -/// 刻意不复述「我们下发了什么」而是回读系统状态:静态模式下 DNS 能不能生效 -/// 取决于 IPMonitor 认不认这个服务,只有回读才能给用户准确的反馈。 +/// It deliberately does not repeat "what we pushed down" but reads the system +/// state back: in static mode whether the DNS takes effect depends on +/// IPMonitor accepting the service, and only a readback can give the user +/// accurate feedback. typedef struct tk_net_state { bool has_address; char address[TK_ADDRESS_CAPACITY]; @@ -473,38 +570,47 @@ typedef struct tk_net_state { char router[TK_ADDRESS_CAPACITY]; char dns[TK_DNS_MAX][TK_ADDRESS_CAPACITY]; int32_t dns_count; - /// IPConfiguration 汇报的配置方式("DHCP" / "MANUAL" / "NONE" / "")。 + /// The configuration method IPConfiguration reports ("DHCP" / "MANUAL" / + /// "NONE" / ""). char method[16]; - /// IPConfiguration 汇报的服务状态(如 "BOUND")。 + /// The service state IPConfiguration reports (e.g. "BOUND"). char service_state[24]; - /// 本接口上是否存在一条默认路由(scoped 或全局)。 + /// Whether a default route (scoped or global) exists on this interface. bool has_default_route; - /// 全局默认路由当前是否指向本接口。 + /// Whether the global default route currently points at this interface. bool is_primary_default_route; } tk_net_state_t; -/// 按配置给网卡配 IP。需要 root。 +/// Configures the IP of an interface according to the configuration. Needs +/// root. TK_API tk_result_t tk_net_apply(const char* interface_name, const tk_ip_config_t* config, tk_error_t* out_error); -/// 撤销网卡上的 IP 配置(`ipconfig set NONE`)。需要 root。 +/// Revokes the IP configuration on an interface (`ipconfig set NONE`). +/// Needs root. TK_API tk_result_t tk_net_clear(const char* interface_name, tk_error_t* out_error); -/// 回读网卡真实生效的 IP 状态。**不需要 root**。 +/// Reads back the IP state really in effect on an interface. **Does not need +/// root**. TK_API tk_result_t tk_net_query(const char* interface_name, tk_net_state_t* out_state, tk_error_t* out_error); // ============================================================================= -// 孤儿网卡清理(需要 root) +// Orphan interface cleanup (root required) // ============================================================================= -/// 销毁进程被强杀后残留在内核里的 feth 接口。 +/// Destroys the feth interfaces left behind in the kernel after the process +/// was forcibly killed. /// -/// 为什么需要:进程被 SIGKILL 时 C++ 的析构不会跑,feth 会留在内核里;信号 -/// 处理器拦不住 SIGKILL,所以只能靠**下次启动时兜底清理**。会话在创建 feth 时 -/// 会把接口名记进 `/var/run/tetherkit-interfaces`,本函数读它并逐个销毁。 +/// Why it is needed: when the process is SIGKILLed the C++ destructors do +/// not run and the feth stays in the kernel; a signal handler cannot catch +/// SIGKILL, so the only option is a **cleanup backstop at the next start**. +/// The session records the interface names in `/var/run/tetherkit-interfaces` +/// when it creates the feth; this function reads it and destroys them one by +/// one. /// -/// @param out_removed 写入实际销毁的接口数,可为 NULL。 +/// @param out_removed Receives the number of interfaces actually destroyed; +/// may be NULL. TK_API tk_result_t tk_cleanup_orphan_interfaces(size_t* out_removed, tk_error_t* out_error); #if defined(__cplusplus) diff --git a/include/tetherkit/common/byte_order.h b/include/tetherkit/common/byte_order.h index 6ec6a02..ac6c656 100644 --- a/include/tetherkit/common/byte_order.h +++ b/include/tetherkit/common/byte_order.h @@ -1,15 +1,18 @@ -// 线格式字节序读写。 +// Wire-format byte order read/write. // -// RNDIS 的线格式**固定小端**(协议源自 Windows NDIS),而以太帧头里的 -// EtherType 等字段是大端。两种都要用,因此这里同时提供 Le/Be 两族函数。 +// The RNDIS wire format is **little-endian** (the protocol comes from Windows NDIS), +// while fields in Ethernet headers such as EtherType are big-endian. Both are needed, +// so this header provides both Le/Be families of functions. // -// 实现要点: -// * 全部走 std::memcpy 而非指针强转,避免未对齐访问的未定义行为 —— -// RNDIS 消息在 USB 传输缓冲里的起始偏移只保证 4 字节对齐,而 -// REMOTE_NDIS_PACKET_MSG 之后紧跟的以太帧起始偏移可能是任意值。 -// clang 会把「memcpy 到局部变量 + byteswap」优化成单条 ldr/rev 指令, -// 所以这是零成本抽象。 -// * 用 std::endian 在编译期分支,big-endian 主机上也正确(虽然 macOS 不存在)。 +// Implementation notes: +// * Use std::memcpy rather than pointer casts to avoid undefined behavior on +// unaligned accesses -- RNDIS messages in USB transfer buffers are only guaranteed +// 4-byte aligned at their start, and the Ethernet frame that follows a +// REMOTE_NDIS_PACKET_MSG may start at any offset. +// Clang optimizes "memcpy to a local variable + byteswap" into a single ldr/rev +// instruction, so this is a zero-cost abstraction. +// * Use std::endian for compile-time branching so it works correctly on big-endian +// hosts too (though macOS is not big-endian). #pragma once #include @@ -20,12 +23,12 @@ namespace tetherkit { -/// 线格式里出现的无符号整数类型。 +/// Unsigned integer types that appear in wire formats. template concept WireUnsigned = std::unsigned_integral && !std::same_as && (sizeof(T) == 1 || sizeof(T) == 2 || sizeof(T) == 4 || sizeof(T) == 8); -/// 从小端字节序读出一个整数。 +/// Read an integer from little-endian byte order. template [[nodiscard]] inline T LoadLe(const std::byte* src) noexcept { T value{}; @@ -36,7 +39,7 @@ template return value; } -/// 把一个整数按小端字节序写入。 +/// Write an integer in little-endian byte order. template inline void StoreLe(std::byte* dst, T value) noexcept { if constexpr (sizeof(T) > 1 && std::endian::native == std::endian::big) { @@ -45,7 +48,7 @@ inline void StoreLe(std::byte* dst, T value) noexcept { std::memcpy(dst, &value, sizeof(T)); } -/// 从大端(网络字节序)读出一个整数。 +/// Read an integer from big-endian (network byte order). template [[nodiscard]] inline T LoadBe(const std::byte* src) noexcept { T value{}; @@ -56,7 +59,7 @@ template return value; } -/// 把一个整数按大端(网络字节序)写入。 +/// Write an integer in big-endian (network byte order). template inline void StoreBe(std::byte* dst, T value) noexcept { if constexpr (sizeof(T) > 1 && std::endian::native == std::endian::little) { @@ -65,7 +68,8 @@ inline void StoreBe(std::byte* dst, T value) noexcept { std::memcpy(dst, &value, sizeof(T)); } -// 常用宽度的便捷别名,让协议解析代码读起来更贴近规范文档里的字段类型。 +// Convenience aliases for common widths, so protocol parsing code reads closer to the +// field types in the specification. [[nodiscard]] inline std::uint16_t LoadLe16(const std::byte* p) noexcept { return LoadLe(p); } @@ -98,13 +102,13 @@ inline void StoreBe16(std::byte* p, std::uint16_t v) noexcept { StoreBe(p, v); } -/// 向上对齐到 `alignment` 的整数倍。`alignment` 必须是 2 的幂。 +/// Round up to the next integer multiple of `alignment`. `alignment` must be a power of two. template [[nodiscard]] constexpr T AlignUp(T value, T alignment) noexcept { return (value + alignment - 1) & ~(alignment - 1); } -/// 判断是否为 2 的幂(0 不是)。 +/// Return whether the value is a power of two (0 is not). template [[nodiscard]] constexpr bool IsPowerOfTwo(T value) noexcept { return value != 0 && (value & (value - 1)) == 0; diff --git a/include/tetherkit/common/cache.h b/include/tetherkit/common/cache.h index 8fed9dc..dd3c2e2 100644 --- a/include/tetherkit/common/cache.h +++ b/include/tetherkit/common/cache.h @@ -1,25 +1,27 @@ -// 缓存相关的编译期常量。 +// Compile-time constants related to caching. #pragma once #include namespace tetherkit { -/// 缓存行大小。硬编码 128。 +/// Cache line size. Hardcoded to 128. /// -/// 为什么是 128:Apple Silicon 的 `sysctl hw.cachelinesize` 报 **128**。 -/// 按习惯性的 64 对齐的话,生产者与消费者的索引仍可能落在同一条 128 字节 -/// 缓存行上,false sharing 依然存在 —— 实测跨核弹跳会让无锁队列的每条目成本 -/// 从个位数纳秒涨到 60+ 纳秒。 +/// Why 128: Apple Silicon reports **128** for `sysctl hw.cachelinesize`. +/// Aligning to the usual 64 bytes means the producer and consumer indices can still land +/// on the same 128-byte cache line, so false sharing remains -- measured cross-core +/// bouncing raises the per-entry cost of the lock-free queue from single-digit nanoseconds +/// to 60+ nanoseconds. /// -/// 为什么**不用** `std::hardware_destructive_interference_size`: -/// 它在 Apple libc++ 上确实存在(在 `` 里,`__cpp_lib_hardware_interference_size` -/// = 201703),但报的是 **256**(constructive 报 64),与真实的 128 不符。 -/// 用它会把所有填充翻倍,白白浪费一倍的缓存与内存,还会引入跨 TU 的 ABI 警告。 -/// 实测硬件值才是对的,所以硬编码。 +/// Why **not** `std::hardware_destructive_interference_size`: +/// It does exist in Apple libc++ (in ``, `__cpp_lib_hardware_interference_size` +/// = 201703), but it reports **256** (constructive reports 64), which does not match the +/// real 128. Using it doubles every padding, wastes twice the cache and memory, and +/// introduces cross-TU ABI warnings. +/// The measured hardware value is the right one, so it is hardcoded. inline constexpr std::size_t kCacheLineSize = 128; -/// 让成员独占一条缓存行的对齐说明符。 +/// Alignment specifier that gives a member its own cache line. #define TETHERKIT_CACHE_ALIGNED alignas(::tetherkit::kCacheLineSize) } // namespace tetherkit diff --git a/include/tetherkit/common/error.h b/include/tetherkit/common/error.h index 7b9b67b..72b041c 100644 --- a/include/tetherkit/common/error.h +++ b/include/tetherkit/common/error.h @@ -1,11 +1,13 @@ -// 统一的错误表示与传播工具。 +// Unified error representation and propagation utilities. // -// 分层原则(全项目一致): -// * 初始化路径 / 控制路径 —— 用 Result 传播错误,错误对象携带来源域、 -// 原始错误码与人类可读的上下文串,便于把「libusb 返回 -3」翻译成 -// 「声明 RNDIS 数据接口失败:LIBUSB_ERROR_ACCESS(需要 root)」。 -// * 数据热路径 —— **绝不**使用本头文件。Result 里的 std::string 会分配堆内存, -// 在 25k~80k pps 下不可接受。热路径用返回计数 + 原子统计计数器表达失败。 +// Layering rules (consistent across the project): +// * Initialization / control path -- propagate errors with Result. The error carries +// the source domain, the raw code, and a human-readable context string, so "libusb +// returned -3" can be translated into +// "failed to claim RNDIS data interface: LIBUSB_ERROR_ACCESS (root required)". +// * Data hot path -- **never** use this header. The std::string inside Result +// allocates heap memory, which is unacceptable at 25k-80k pps. Hot paths express +// failures as return counts plus atomic statistics counters. #pragma once #include @@ -19,46 +21,50 @@ namespace tetherkit { namespace detail { -/// 「外层原因 <分隔符> 内层原因」里的那个分隔符,随当前语言变化。 +/// The separator in "outer cause inner cause", which varies with the current +/// language. /// -/// 在这里前置声明(实现在 common/i18n.cc)而不是直接 include i18n.h: -/// error.h 被全项目包含,让它拖上 与整张文案表不划算。 +/// Forward-declared here (implemented in common/i18n.cc) instead of including i18n.h +/// directly: error.h is included across the project, and pulling in and the +/// whole message table would be expensive. [[nodiscard]] std::string_view ContextSeparator() noexcept; } // namespace detail -/// 错误码的来源域。决定 `code` 字段该怎么翻译成文字。 +/// Source domain of an error code. Determines how the `code` field is translated to text. enum class ErrorDomain : std::uint8_t { - kGeneric, ///< 纯逻辑错误,`code` 无意义。 - kErrno, ///< POSIX errno(open / ioctl / read / write 等系统调用)。 - kLibUsb, ///< libusb 的 enum libusb_error(负值)。 - kRndis, ///< RNDIS_STATUS_* 状态码(32 位无符号)。 + kGeneric, ///< Pure logic error; `code` is meaningless. + kErrno, ///< POSIX errno (open / ioctl / read / write and other system calls). + kLibUsb, ///< libusb enum libusb_error (negative values). + kRndis, ///< RNDIS_STATUS_* status codes (32-bit unsigned). }; -/// 一次失败的完整描述。 +/// Complete description of a failure. /// -/// 刻意做成值类型而非异常:本项目的错误绝大多数是「预期内的环境问题」 -/// (没插设备、没有 root、内核驱动占用接口),调用方总要处理,不该靠异常传播。 +/// Deliberately a value type rather than an exception: most errors in this project are +/// "expected environment problems" (no device plugged in, not running as root, interface +/// held by a kernel driver). Callers always have to handle them, so they should not +/// propagate via exceptions. class Error { public: Error() = default; - /// 纯逻辑错误,只有描述。 + /// Pure logic error with only a description. static Error Generic(std::string context) { return Error{ErrorDomain::kGeneric, 0, std::move(context)}; } - /// 包装 POSIX errno。`err` 传 0 时自动读取全局 errno。 + /// Wrap a POSIX errno. When `err` is 0, the global errno is read automatically. static Error FromErrno(int err, std::string context) { return Error{ErrorDomain::kErrno, err != 0 ? err : errno, std::move(context)}; } - /// 包装 libusb 错误码(libusb 的错误码是负值,此处原样保留)。 + /// Wrap a libusb error code (libusb error codes are negative and kept as-is). static Error FromLibUsb(int rc, std::string context) { return Error{ErrorDomain::kLibUsb, rc, std::move(context)}; } - /// 包装 RNDIS_STATUS_*。 + /// Wrap RNDIS_STATUS_*. static Error FromRndisStatus(std::uint32_t status, std::string context) { return Error{ErrorDomain::kRndis, static_cast(status), std::move(context)}; } @@ -69,16 +75,19 @@ class Error { [[nodiscard]] std::string_view Context() const noexcept { return context_; } - /// 在已有错误上追加一层上下文,形成「外层原因:内层原因」的链条。 - /// 用法:`return std::unexpected(std::move(e).WithContext(Tr(Msg::kNetOpenBpfFailed)));` + /// Append a layer of context to an existing error, forming an + /// "outer cause: inner cause" chain. + /// Usage: `return std::unexpected(std::move(e).WithContext(Tr(Msg::kNetOpenBpfFailed)));` /// - /// 分隔符随语言变化(中文是全角冒号、英文是 ": "),所以不能写死在这里。 + /// The separator varies with the language (Chinese uses a full-width colon, English + /// uses ": "), so it cannot be hardcoded here. [[nodiscard]] Error WithContext(std::string_view outer) && { context_ = std::string{outer} + std::string{detail::ContextSeparator()} + context_; return std::move(*this); } - /// 渲染成形如 `声明数据接口失败 [libusb: LIBUSB_ERROR_ACCESS(-3)]` 的可读串。 + /// Render as a human-readable string such as + /// `failed to claim data interface [libusb: LIBUSB_ERROR_ACCESS(-3)]`. [[nodiscard]] std::string ToString() const; private: @@ -90,30 +99,30 @@ class Error { ErrorDomain domain_ = ErrorDomain::kGeneric; }; -/// 可能失败并返回值的操作。 +/// An operation that may fail and return a value. template using Result = std::expected; -/// 可能失败但无返回值的操作。 +/// An operation that may fail but has no return value. using Status = std::expected; -/// 成功的 Status 字面量。 +/// Literal for a successful Status. inline Status Ok() noexcept { return Status{}; } namespace detail { -// 用于生成唯一临时变量名,避免宏展开时的变量遮蔽。 +// Generate unique temporary variable names to avoid shadowing during macro expansion. #define TETHERKIT_CONCAT_INNER(a, b) a##b #define TETHERKIT_CONCAT(a, b) TETHERKIT_CONCAT_INNER(a, b) #define TETHERKIT_UNIQUE(base) TETHERKIT_CONCAT(base, __LINE__) } // namespace detail -/// 若表达式失败则立即向上传播错误。 +/// Propagate the error upward immediately if the expression fails. /// -/// 用法:`TETHERKIT_RETURN_IF_ERROR(link.Configure(mtu));` +/// Usage: `TETHERKIT_RETURN_IF_ERROR(link.Configure(mtu));` #define TETHERKIT_RETURN_IF_ERROR(expr) \ do { \ auto TETHERKIT_UNIQUE(tk_status_) = (expr); \ @@ -122,11 +131,12 @@ namespace detail { } \ } while (false) -/// 取出成功值赋给新变量,失败则向上传播错误。 +/// Assign the success value to a new variable, or propagate the error upward on failure. /// -/// 用法:`TETHERKIT_ASSIGN_OR_RETURN(auto fd, OpenBpfDevice());` +/// Usage: `TETHERKIT_ASSIGN_OR_RETURN(auto fd, OpenBpfDevice());` /// -/// `decl` 是一条声明而非表达式,不能加括号,故此处豁免 macro-parentheses 检查。 +/// `decl` is a declaration rather than an expression and cannot be parenthesized, so the +/// macro-parentheses check is suppressed here. // NOLINTNEXTLINE(bugprone-macro-parentheses) #define TETHERKIT_ASSIGN_OR_RETURN(decl, expr) \ auto TETHERKIT_UNIQUE(tk_result_) = (expr); \ diff --git a/include/tetherkit/common/frame_ring.h b/include/tetherkit/common/frame_ring.h index 2a62f7f..a9d75e6 100644 --- a/include/tetherkit/common/frame_ring.h +++ b/include/tetherkit/common/frame_ring.h @@ -1,25 +1,30 @@ -// 变长以太帧的 SPSC 无锁环形队列。 +// SPSC lock-free ring queue for variable-length Ethernet frames. // -// 与 SpscRing 的区别:以太帧是变长的(14~2048 字节),而且我们希望 -// **整条数据路径上只发生一次内存拷贝**。因此这里提供「预留槽位 → 直接写入 → -// 提交」的接口,而不是「先在别处组装好再入队」: +// Difference from SpscRing: Ethernet frames are variable-length (14-2048 +// bytes), and we want **exactly one memory copy on the whole data path**. So +// instead of "assemble elsewhere, then enqueue", this offers a +// "reserve slot -> write in place -> commit" interface: // -// 生产者(libusb 回调线程) 消费者(BPF 写线程) +// Producer (libusb callback thread) Consumer (BPF write thread) // ------------------------------------ ----------------------------------- // std::byte* dst = ring.BeginWrite(); auto view = ring.BeginRead(); -// memcpy(dst, usb_payload, len); ← 唯一 ::write(bpf_fd, view.data(), ...); -// ring.CommitWrite(len); 一次 ring.CommitRead(); -// 拷贝 +// memcpy(dst, usb_payload, len); ::write(bpf_fd, view.data(), ...); +// ring.CommitWrite(len); ring.CommitRead(); +// (the memcpy is the single copy) // -// 为什么必须拷贝这一次(而不是零拷贝): -// libusb 的 bulk IN 传输缓冲要尽快 resubmit 才能维持 USB 管道满载;如果把 -// 缓冲交给下游持有,就必须准备远多于「在飞传输数」的缓冲区,并引入归还机制。 -// 实测 1500 字节的 memcpy 在 Apple Silicon 上约 40~60 ns,而一次 BPF write() -// 系统调用是它的 30~50 倍 —— 拷贝根本不是瓶颈,为它做零拷贝是错误的优化方向。 +// Why this one copy is mandatory (instead of zero-copy): +// libusb's bulk IN transfer buffers must be resubmitted quickly to keep the +// USB pipe saturated; handing buffers to the downstream would require far +// more buffers than "in-flight transfers" plus a return mechanism. A measured +// 1500-byte memcpy on Apple Silicon costs about 40-60 ns, while a single BPF +// write() syscall costs 30-50x that -- the copy is not the bottleneck, and +// zero-copy past it is the wrong optimization. // -// 存储布局:容量个定长槽位,每槽 = 缓存行对齐的 header(长度) + 帧数据区。 -// 定长槽位而非紧凑 arena 的理由:arena 需要处理跨越环尾的回绕拼接,会让 -// 「一次 write() 发一帧」变成两次,反而更慢。 +// Storage layout: capacity fixed-size slots, each slot = a cache-line-aligned +// header (length) + frame data area. +// Why fixed slots instead of a compact arena: an arena must splice frames +// across the ring tail on wraparound, which turns "one write() per frame" into +// two and is slower. #pragma once #include @@ -34,18 +39,20 @@ namespace tetherkit { -/// 以太帧的最大字节数(不含 FCS)。 +/// Maximum bytes of an Ethernet frame (excluding FCS). /// -/// 取 2048 的理由:feth 的 `net.link.fake.max_mtu` 是 2048,因此 MTU 上限 -/// 就是 2048,加上 14 字节以太头本应是 2062;但 RNDIS 设备可能协商出更大的 -/// `OID_GEN_MAXIMUM_FRAME_SIZE`,且槽位按 2 的幂对齐更利于地址计算, -/// 故直接取 2048 作为帧数据区容量,超长帧在入队前就被丢弃并计数。 +/// Why 2048: feth's `net.link.fake.max_mtu` is 2048, so the MTU ceiling is +/// 2048, add the 14-byte Ethernet header and it should be 2062; but an RNDIS +/// device may negotiate a larger `OID_GEN_MAXIMUM_FRAME_SIZE`, and power-of-two +/// slot sizes favor address math, so 2048 is taken directly as the frame data +/// capacity; oversize frames are dropped and counted before enqueue. inline constexpr std::uint32_t kMaxEthernetFrameBytes = 2048; -/// 最小合法以太帧(目的 MAC 6 + 源 MAC 6 + EtherType 2)。 +/// Minimum legal Ethernet frame (destination MAC 6 + source MAC 6 + EtherType 2). inline constexpr std::uint32_t kMinEthernetFrameBytes = 14; -/// 只读的帧视图,指向环形队列内部存储,在 CommitRead 之前有效。 +/// Read-only view of a frame pointing into the ring's internal storage, valid +/// until CommitRead. struct FrameView { const std::byte* data = nullptr; std::uint32_t length = 0; @@ -55,11 +62,11 @@ struct FrameView { [[nodiscard]] bool Empty() const noexcept { return length == 0; } }; -/// 变长帧的 SPSC 无锁有界队列。 +/// SPSC lock-free bounded queue of variable-length frames. class FrameRing { public: - /// `capacity_frames` 会被向上取整到 2 的幂。 - /// `max_frame_bytes` 为单帧上限,默认 kMaxEthernetFrameBytes。 + /// `capacity_frames` is rounded up to a power of two. + /// `max_frame_bytes` is the per-frame limit, defaulting to kMaxEthernetFrameBytes. explicit FrameRing(std::size_t capacity_frames, std::uint32_t max_frame_bytes = kMaxEthernetFrameBytes) : cursor_(RoundUpToPowerOfTwo(capacity_frames)), @@ -67,9 +74,10 @@ class FrameRing { slot_stride_(ComputeSlotStride(max_frame_bytes)), storage_(AllocateStorage(RoundUpToPowerOfTwo(capacity_frames), ComputeSlotStride(max_frame_bytes))) { - // 存储起点对齐到缓存行后缓存下来,避免每次访问槽位都重算一遍对齐。 - // 用 std::align 而非整数取模:后者需要 uintptr_t ↔ 指针互转,会削弱 - // 编译器的别名分析(clang-tidy performance-no-int-to-ptr 就在提示这点)。 + // Cache the storage start aligned to a cache line to avoid recomputing alignment on + // every slot access. Use std::align rather than integer modulo: the latter requires + // uintptr_t <-> pointer conversions which weaken the compiler's alias analysis + // (clang-tidy performance-no-int-to-ptr flags this). void* base = storage_.get(); std::size_t space = cursor_.Capacity() * slot_stride_ + kCacheLineSize; void* aligned = std::align(kCacheLineSize, cursor_.Capacity() * slot_stride_, base, space); @@ -87,18 +95,20 @@ class FrameRing { [[nodiscard]] std::uint32_t MaxFrameBytes() const noexcept { return max_frame_bytes_; } - /// 队列占用的总字节数,用于启动时打印内存预算。 + /// Total bytes occupied by the queue, used to print the memory budget at startup. [[nodiscard]] std::size_t StorageBytes() const noexcept { return cursor_.Capacity() * slot_stride_; } // --------------------------------------------------------------------------- - // 生产者侧 + // Producer side // --------------------------------------------------------------------------- - /// 预留一个槽位,返回可写入 `MaxFrameBytes()` 字节的缓冲区;队列满返回空 span。 + /// Reserve a slot and return a buffer writable for `MaxFrameBytes()` bytes; returns an + /// empty span when the queue is full. /// - /// 必须与 CommitWrite 配对;两次调用之间不得再调用 BeginWrite。 + /// Must be paired with CommitWrite; BeginWrite must not be called again between the + /// two. [[nodiscard]] std::span BeginWrite() noexcept { if (!cursor_.TryAcquireWrite(pending_write_index_)) [[unlikely]] { return {}; @@ -106,14 +116,15 @@ class FrameRing { return {FrameDataAt(pending_write_index_), max_frame_bytes_}; } - /// 提交刚写入的帧。`length` 必须 <= MaxFrameBytes()。 + /// Commit the frame that was just written. `length` must be <= MaxFrameBytes(). void CommitWrite(std::uint32_t length) noexcept { assert(length <= max_frame_bytes_); StoreLength(pending_write_index_, length); cursor_.PublishWrite(); } - /// 便捷入口:拷贝一整帧进队列。成功返回 true,队列满或帧超长返回 false。 + /// Convenience entry point: copy a whole frame into the queue. Returns true on success, + /// or false when the queue is full or the frame exceeds the limit. [[nodiscard]] bool TryPush(std::span frame) noexcept { if (frame.size() > max_frame_bytes_) [[unlikely]] { return false; @@ -127,25 +138,29 @@ class FrameRing { return true; } - /// 生产者视角的空闲槽数。用于在拆包前判断能否容纳整批,避免半批丢弃。 + /// Free slots from the producer's perspective. Used before unpacking to check whether + /// the whole batch fits and avoid dropping half a batch. [[nodiscard]] std::size_t WritableApprox() noexcept { return cursor_.WritableApprox(); } - /// 批量写入会话。**数据路径上应当一律用它,而不是逐帧 TryPush。** + /// Batch write session. **Data paths should always use this instead of per-frame + /// TryPush.** /// - /// 一次 release store 会让写位置所在的缓存行跨核弹一次(实测约 60 ns)。 - /// 逐帧发布时那就是每帧的固定成本;批量发布则摊薄成 60/N: - /// batch=1 约 63 ns/帧、batch=8 约 5.5 ns/帧、batch=32 约 1.9 ns/帧。 + /// One release store bounces the write-position cache line across cores once (measured + /// at about 60 ns). With per-frame publishing that is the fixed cost per frame; batch + /// publishing amortizes it to 60/N: + /// batch=1 about 63 ns/frame, batch=8 about 5.5 ns/frame, batch=32 about 1.9 + /// ns/frame. /// - /// 用法(libusb 回调里拆一个传输的多个 RNDIS 包): + /// Usage (unpacking multiple RNDIS packets from one transfer in a libusb callback): /// ``` /// auto batch = ring.BeginBatchWrite(); /// while (reader.Next(frame) == ReadOutcome::kFrame) { /// const std::span dst = batch.Begin(); - /// if (dst.empty()) { break; } // 队列满 + /// if (dst.empty()) { break; } // queue full /// std::memcpy(dst.data(), frame.data(), frame.size()); /// batch.Commit(frame.size()); /// } - /// // batch 析构时一次性发布全部帧 + /// // batch destructor publishes all frames at once /// ``` class BatchWrite { public: @@ -156,10 +171,11 @@ class FrameRing { BatchWrite(BatchWrite&&) = delete; BatchWrite& operator=(BatchWrite&&) = delete; - /// 析构时自动发布。忘记显式 Publish 也不会丢数据。 + /// Auto-publish on destruction. Forgetting an explicit Publish does not lose data. ~BatchWrite() { Publish(); } - /// 预留下一个槽位,返回可写缓冲;队列满返回空 span。 + /// Reserve the next slot and return a writable buffer; returns an empty span when the + /// queue is full. [[nodiscard]] std::span Begin() noexcept { if (!ring_->cursor_.TryAcquireWriteAt(staged_, staged_index_)) [[unlikely]] { return {}; @@ -167,14 +183,15 @@ class FrameRing { return {ring_->FrameDataAt(staged_index_), ring_->max_frame_bytes_}; } - /// 记录刚写入的帧长。**不做任何原子操作** —— 这是批量化的关键。 + /// Record the length of the frame that was just written. **No atomic operations** -- + /// this is the key to batching. void Commit(std::uint32_t length) noexcept { assert(length <= ring_->max_frame_bytes_); ring_->StoreLength(staged_index_, length); ++staged_; } - /// 便捷入口:拷贝一整帧。成功返回 true。 + /// Convenience entry point: copy a whole frame. Returns true on success. [[nodiscard]] bool Push(std::span frame) noexcept { if (frame.size() > ring_->max_frame_bytes_) [[unlikely]] { return false; @@ -188,7 +205,8 @@ class FrameRing { return true; } - /// 立即发布已暂存的全部帧。可提前调用;之后本会话可继续暂存新帧。 + /// Publish all staged frames immediately. May be called early; this session can + /// continue staging new frames afterward. void Publish() noexcept { if (staged_ != 0) { ring_->cursor_.PublishWrite(staged_); @@ -197,10 +215,10 @@ class FrameRing { } } - /// 本会话已暂存但未发布的帧数。 + /// Number of frames staged but not yet published in this session. [[nodiscard]] std::uint32_t Staged() const noexcept { return staged_; } - /// 本会话累计已发布的帧数。 + /// Cumulative number of frames published in this session. [[nodiscard]] std::uint32_t Published() const noexcept { return published_; } private: @@ -213,12 +231,13 @@ class FrameRing { [[nodiscard]] BatchWrite BeginBatchWrite() noexcept { return BatchWrite{*this}; } // --------------------------------------------------------------------------- - // 消费者侧 + // Consumer side // --------------------------------------------------------------------------- - /// 取出队首帧的只读视图;队列空时返回 length == 0 的视图。 + /// Take a read-only view of the head frame; returns a view with length == 0 when the + /// queue is empty. /// - /// 视图在下一次 CommitRead 之前有效。必须与 CommitRead 配对。 + /// The view is valid until the next CommitRead. Must be paired with CommitRead. [[nodiscard]] FrameView BeginRead() noexcept { if (!cursor_.TryAcquireRead(pending_read_index_)) [[unlikely]] { return FrameView{.data = nullptr, .length = 0}; @@ -227,28 +246,30 @@ class FrameRing { .length = LoadLength(pending_read_index_)}; } - /// 释放队首帧。 + /// Release the head frame. void CommitRead() noexcept { cursor_.PublishRead(); } - /// 消费者视角的可读帧数。批量消费时用它一次拿到批大小。 + /// Readable frames from the consumer's perspective. When consuming in batches, use this + /// to get the batch size once. [[nodiscard]] std::size_t ReadableApprox() noexcept { return cursor_.ReadableApprox(); } - /// 批量读取会话。与 BatchWrite 对称,只在批次结束时做一次 release store。 + /// Batch read session. Symmetric with BatchWrite; does only one release store at the + /// end of the batch. /// - /// 这正是 BPF 写线程要的形态:一次取出几十帧,攒成一个 BIOCSBATCHWRITE - /// 批量写出去,然后一次性释放全部槽位。 + /// This is exactly the shape the BPF write thread needs: take out dozens of frames at + /// once, collect them into one BIOCSBATCHWRITE batch, then release all slots at once. /// - /// 用法: + /// Usage: /// ``` /// auto batch = ring.BeginBatchRead(); /// std::vector views; /// while (views.size() < kMaxBatch) { /// const FrameView view = batch.Next(); /// if (view.Empty()) { break; } - /// views.push_back(view); // 视图在 batch 析构前有效 + /// views.push_back(view); // view valid until batch is destroyed /// } /// link.WriteFrames(views); - /// // batch 析构时一次性释放全部槽位 + /// // batch destructor releases all slots at once /// ``` class BatchRead { public: @@ -259,13 +280,15 @@ class FrameRing { BatchRead(BatchRead&&) = delete; BatchRead& operator=(BatchRead&&) = delete; - /// 析构时自动释放已取出的槽位。 + /// Auto-release taken slots on destruction. ~BatchRead() { Release(); } - /// 取下一帧的只读视图;无更多帧时返回 length == 0 的视图。 + /// Take a read-only view of the next frame; returns a view with length == 0 when + /// there are no more frames. /// - /// 返回的视图在本会话 Release()(或析构)**之前**有效 —— 因为槽位尚未 - /// 归还给生产者,内容不会被覆写。这正是零拷贝批量写出的前提。 + /// The returned view is valid **until** this session's Release() (or destruction) -- + /// because the slot has not been returned to the producer, its contents cannot be + /// overwritten. This is the precondition for zero-copy batch writes. [[nodiscard]] FrameView Next() noexcept { std::size_t index = 0; if (!ring_->cursor_.TryAcquireReadAt(staged_, index)) [[unlikely]] { @@ -276,7 +299,8 @@ class FrameRing { .length = ring_->LoadLength(index)}; } - /// 立即释放已取出的槽位。调用后之前返回的视图全部失效。 + /// Release all taken slots immediately. All previously returned views become invalid + /// after this call. void Release() noexcept { if (staged_ != 0) { ring_->cursor_.PublishRead(staged_); @@ -285,10 +309,10 @@ class FrameRing { } } - /// 已取出但未释放的帧数。 + /// Number of frames taken but not yet released. [[nodiscard]] std::uint32_t Staged() const noexcept { return staged_; } - /// 本会话累计已释放的帧数。 + /// Cumulative number of frames released in this session. [[nodiscard]] std::uint32_t Released() const noexcept { return released_; } private: @@ -300,7 +324,7 @@ class FrameRing { [[nodiscard]] BatchRead BeginBatchRead() noexcept { return BatchRead{*this}; } // --------------------------------------------------------------------------- - // 观测 + // Observation // --------------------------------------------------------------------------- [[nodiscard]] std::size_t SizeSnapshot() const noexcept { return cursor_.SizeSnapshot(); } @@ -310,14 +334,15 @@ class FrameRing { [[nodiscard]] std::uint64_t TotalDequeued() const noexcept { return cursor_.TotalDequeued(); } private: - // 两个批量会话类需要直接访问游标与槽位寻址,避免在热路径上多一层转发。 + // The two batch session classes need direct access to the cursor and slot addressing to + // avoid an extra forwarding layer on hot paths. friend class BatchWrite; friend class BatchRead; - /// 槽位内布局:[0, 4) 存长度,[kSlotHeaderBytes, ...) 存帧数据。 + /// Slot layout: [0, 4) stores the length, [kSlotHeaderBytes, ...) stores frame data. /// - /// 帧数据从 kCacheLineSize 偏移开始,保证每帧的起始地址都是缓存行对齐的 —— - /// memcpy 与后续 write() 都能走最优路径。 + /// Frame data starts at offset kCacheLineSize so that every frame's start address is + /// cache-line aligned -- memcpy and subsequent write() can take the optimal path. static constexpr std::size_t kSlotHeaderBytes = kCacheLineSize; static std::size_t RoundUpToPowerOfTwo(std::size_t requested) noexcept { @@ -328,15 +353,17 @@ class FrameRing { return value; } - /// 槽位步长:header + 帧数据区,再向上对齐到缓存行,使每个槽都独占若干整行。 + /// Slot stride: header + frame data area, rounded up to a cache line so each slot + /// occupies a whole number of lines exclusively. static std::size_t ComputeSlotStride(std::uint32_t max_frame_bytes) noexcept { const std::size_t raw = kSlotHeaderBytes + max_frame_bytes; return ((raw + kCacheLineSize - 1) / kCacheLineSize) * kCacheLineSize; } static std::unique_ptr AllocateStorage(std::size_t capacity, std::size_t stride) { - // 用 new[] 而非 aligned_alloc:整块起始地址的对齐由下面的 AlignedBase 处理, - // 这样析构仍然是简单的 unique_ptr,不需要自定义 deleter。 + // Use new[] rather than aligned_alloc: alignment of the block's start address is + // handled by AlignedBase below, so destruction stays a plain unique_ptr without a + // custom deleter. return std::make_unique(capacity * stride + kCacheLineSize); } @@ -360,15 +387,17 @@ class FrameRing { SpscCursor cursor_; - // 构造后只读,两侧线程都只做读取,可以安全共享同一条缓存行。 + // Read-only after construction; both threads only read them, so they can safely share + // the same cache line. std::uint32_t max_frame_bytes_; std::size_t slot_stride_; std::unique_ptr storage_; std::byte* aligned_base_ = nullptr; - // 「已预留但未提交」的槽位下标:生产者与消费者各自私有,**必须**分处不同 - // 缓存行 —— 否则每帧的 BeginWrite/BeginRead 都会互相 invalidate, - // 前面为 SpscCursor 做的缓存行隔离就全白费了。 + // "Reserved but not yet committed" slot indices: private to the producer and consumer + // respectively and **must** live on different cache lines -- otherwise every frame's + // BeginWrite/BeginRead would invalidate each other, and all the cache-line isolation + // done for SpscCursor would be wasted. TETHERKIT_CACHE_ALIGNED std::size_t pending_write_index_ = 0; TETHERKIT_CACHE_ALIGNED std::size_t pending_read_index_ = 0; }; diff --git a/include/tetherkit/common/logging.h b/include/tetherkit/common/logging.h index a110e79..a071ead 100644 --- a/include/tetherkit/common/logging.h +++ b/include/tetherkit/common/logging.h @@ -1,13 +1,15 @@ -// 轻量分级日志。 +// Lightweight leveled logging. // -// 设计约束: -// 1. **禁止出现在数据热路径上。** Trace/Debug 级别在 Release 构建下被 -// 预处理器整段删掉(连参数求值都不会发生),所以在热路径写 TETHERKIT_TRACE -// 是安全的;但 Info 及以上会真的做格式化与加锁写 stderr,热路径禁用。 -// 2. 线程安全:多线程同时写 stderr 会交错,因此在一把互斥锁下整行输出。 -// 日志不在热路径,锁竞争无所谓。 -// 3. 不引入第三方日志库:只需要「时间 + 级别 + 线程名 + 位置 + 消息」, -// std::format 已经够了。 +// Design constraints: +// 1. **Must not appear on data hot paths.** Trace/Debug levels are removed entirely by +// the preprocessor in Release builds (even argument evaluation does not happen), so +// writing TETHERKIT_TRACE on a hot path is safe; but Info and above really do +// formatting and a locked stderr write, so hot paths must not use them. +// 2. Thread-safe: concurrent writes to stderr from multiple threads would interleave, +// so each full line is written under a mutex. Logging is not on hot paths, so lock +// contention does not matter. +// 3. No third-party logging library: "time + level + thread name + location + message" +// is all that is needed, and std::format is enough. #pragma once #include @@ -20,18 +22,22 @@ namespace tetherkit { enum class LogLevel : std::uint8_t { - kTrace = 0, ///< 逐帧级别的细节,Release 构建下编译期删除。 - kDebug = 1, ///< 协议交互细节(每条 RNDIS 控制消息),Release 下编译期删除。 - kInfo = 2, ///< 生命周期事件(设备连接、状态机迁移、统计报告)。 - kWarn = 3, ///< 可恢复异常(USB stall、BPF 丢包、保活超时重试)。 - kError = 4, ///< 不可恢复错误。 - kOff = 5, ///< 全部关闭。 + kTrace = 0, ///< Per-frame details, removed at compile time in Release builds. + kDebug = 1, ///< Protocol interaction details (every RNDIS control message), removed at + ///< compile time in Release builds. + kInfo = 2, ///< Lifecycle events (device connect, state machine transition, statistics + ///< report). + kWarn = 3, ///< Recoverable anomalies (USB stall, BPF frame drop, keep-alive timeout + ///< retry). + kError = 4, ///< Unrecoverable error. + kOff = 5, ///< Fully off. }; -/// 编译期日志下限。低于此级别的日志调用被预处理器整段删除。 +/// Compile-time log floor. Log calls below this level are removed entirely by the +/// preprocessor. /// -/// Release 构建(NDEBUG)下把 Trace/Debug 删掉,确保热路径上的 -/// TETHERKIT_TRACE 连参数都不求值。 +/// In Release builds (NDEBUG), Trace/Debug are removed so that TETHERKIT_TRACE on hot +/// paths does not even evaluate its arguments. #ifndef TETHERKIT_COMPILED_MIN_LOG_LEVEL #ifdef NDEBUG #define TETHERKIT_COMPILED_MIN_LOG_LEVEL 2 // kInfo @@ -40,64 +46,71 @@ enum class LogLevel : std::uint8_t { #endif #endif -/// 运行期日志下限,默认 kInfo。 +/// Runtime log floor, kInfo by default. void SetLogLevel(LogLevel level) noexcept; LogLevel GetLogLevel() noexcept; -/// 是否启用彩色输出。默认仅当 stderr 是 tty 时启用。 +/// Whether to enable colored output. Enabled by default only when stderr is a tty. void SetLogColorEnabled(bool enabled) noexcept; -/// 给当前线程起个短名字,出现在日志行里,便于区分 usb-event / bpf-rx / bpf-tx。 -/// 同时调用 pthread_setname_np,使其在 Instruments / lldb 里也可见。 +/// Give the current thread a short name that appears in log lines, making it easy to +/// distinguish usb-event / bpf-rx / bpf-tx. +/// Also calls pthread_setname_np so the name is visible in Instruments / lldb. void SetCurrentThreadName(std::string_view name) noexcept; -/// 日志汇:把已格式化好的日志行**额外**转交给宿主(GUI 需要在界面上显示日志, -/// 而日志本身只往 stderr 走)。stderr 输出不受影响,两者并行。 +/// Log sink: **additionally** hand each formatted log line to the host (the GUI needs to +/// show logs in the interface, while logging itself only writes to stderr). stderr output +/// is unaffected; the two run in parallel. /// -/// ★ 实现日志汇时的三条硬约束 ★ -/// 1. 会在**任意线程**上被调用(含 libusb 事件线程); -/// 2. 会在日志互斥锁**内部**被调用 —— 因此实现里**绝不能再打日志** -/// (std::mutex 不可重入,会当场死锁); -/// 3. 绝不能做阻塞 I/O —— 它会把所有正在打日志的线程一起拖住。 +/// Three hard constraints when implementing a log sink: +/// 1. It will be called on **any thread** (including the libusb event thread); +/// 2. It will be called **inside** the logging mutex -- so the implementation must +/// **never log** (std::mutex is not reentrant and would deadlock immediately); +/// 3. It must never do blocking I/O -- that would stall every thread that is logging. /// -/// 满足这三条的唯一合理实现就是「拷进定长环形缓冲,由宿主轮询取走」。 +/// The only reasonable implementation that satisfies all three is "copy into a fixed-size +/// ring buffer and let the host poll it out". using LogSink = void (*)(LogLevel level, std::string_view thread_name, std::string_view message, void* user) noexcept; -/// 安装 / 卸载日志汇。传 nullptr 卸载。线程安全。 +/// Install / uninstall the log sink. Pass nullptr to uninstall. Thread-safe. void SetLogSink(LogSink sink, void* user) noexcept; namespace detail { [[nodiscard]] bool IsLogLevelEnabled(LogLevel level) noexcept; -/// 输出一整行日志。`message` 已格式化完毕。 +/// Emit a full log line. `message` is already formatted. void EmitLogLine(LogLevel level, std::string_view file, unsigned line, std::string_view message) noexcept; -/// 从 __FILE__ 里截出文件名,避免日志里出现长路径。 +/// Extract the file name from __FILE__ to avoid long paths in log lines. constexpr std::string_view BaseName(std::string_view path) noexcept { const auto pos = path.find_last_of('/'); return pos == std::string_view::npos ? path : path.substr(pos + 1); } -/// 格式化并输出。故意做成函数模板而非宏内联,缩小宏展开体积。 +/// Format and emit. Deliberately a function template rather than inlined in the macro +/// to keep macro expansion small. template void LogFormatted(LogLevel level, std::string_view file, unsigned line, std::format_string fmt, Args&&... args) noexcept { - // std::format 可能因内存不足抛异常;日志失败绝不应该拖垮驱动。 + // std::format may throw on out-of-memory; a logging failure must never take down the + // driver. try { EmitLogLine(level, file, line, std::format(fmt, std::forward(args)...)); } catch (...) { // NOLINT(bugprone-empty-catch) - // Text() 只是查表返回 string_view,不分配、不抛 —— 在这个 catch 里是安全的。 + // Text() only looks up and returns a string_view, no allocation, no throw -- safe + // in this catch. EmitLogLine(LogLevel::kError, file, line, Text(Msg::kCommonLogFormatFailed)); } } } // namespace detail -/// 日志宏。先做编译期级别裁剪,再做运行期级别判断(分支预测友好)。 +/// Logging macros. Do compile-time level trimming first, then the runtime level check +/// (branch-prediction friendly). #define TETHERKIT_LOG(level, ...) \ do { \ if constexpr (static_cast(level) >= TETHERKIT_COMPILED_MIN_LOG_LEVEL) { \ @@ -114,11 +127,14 @@ void LogFormatted(LogLevel level, std::string_view file, unsigned line, #define TETHERKIT_WARN(...) TETHERKIT_LOG(::tetherkit::LogLevel::kWarn, __VA_ARGS__) #define TETHERKIT_ERROR(...) TETHERKIT_LOG(::tetherkit::LogLevel::kError, __VA_ARGS__) -/// 打一条**可翻译**的日志。`id` 是 `Msg` 枚举值,其后的参数与 std::format 一致。 +/// Log a **translatable** message. `id` is a `Msg` enum value; the remaining arguments +/// match std::format. /// -/// 为什么要有这一组而不是直接写 `TETHERKIT_INFO("{}", Tr(id, ...))`:写成宏才能 -/// 让 Tr() 留在级别判断的**内部**,级别没开时连查表带格式化一起省掉 —— 上面那种 -/// 手写法很容易不小心把 Tr() 放到宏外面,每条日志白算一次。 +/// Why this family exists instead of writing `TETHERKIT_INFO("{}", Tr(id, ...))` +/// directly: as macros, Tr() stays **inside** the level check, so when the level is +/// disabled both the table lookup and formatting are skipped -- the handwritten form +/// above makes it easy to accidentally put Tr() outside the macro and pay for it on +/// every log line. #define TETHERKIT_LOG_TR(level, id, ...) \ TETHERKIT_LOG(level, "{}", ::tetherkit::Tr((id)__VA_OPT__(, ) __VA_ARGS__)) diff --git a/include/tetherkit/common/scheduling.h b/include/tetherkit/common/scheduling.h index 8bc10ed..aefb354 100644 --- a/include/tetherkit/common/scheduling.h +++ b/include/tetherkit/common/scheduling.h @@ -1,21 +1,26 @@ -// 线程调度策略。 +// Thread scheduling policy. // -// 为什么需要它:Apple Silicon 是 big.LITTLE 架构(本机 10 核 = 4 性能核 + -// 6 效率核)。默认 QoS 下,数据路径线程可能被调度到效率核上,吞吐会明显下降, -// 而且抖动变大。macOS **不提供**把线程绑定到特定物理核的接口 -// (没有 sched_setaffinity),能做的是通过 QoS class 表达意图,由内核决定: +// Why this is needed: Apple Silicon is a big.LITTLE architecture (this machine has 10 +// cores = 4 performance cores + 6 efficiency cores). Under default QoS, data-path +// threads may be scheduled onto efficiency cores, which visibly reduces throughput and +// increases jitter. macOS **does not provide** an interface for pinning threads to +// specific physical cores (no sched_setaffinity); all we can do is express intent +// through a QoS class and let the kernel decide: // -// QOS_CLASS_USER_INTERACTIVE —— 最高优先级,内核优先安排到性能核。 -// QOS_CLASS_USER_INITIATED —— 次之。 -// QOS_CLASS_DEFAULT —— 默认。 -// QOS_CLASS_UTILITY / BACKGROUND —— 倾向效率核。 +// QOS_CLASS_USER_INTERACTIVE -- highest priority, kernel prefers performance cores. +// QOS_CLASS_USER_INITIATED -- next. +// QOS_CLASS_DEFAULT -- default. +// QOS_CLASS_UTILITY / BACKGROUND -- prefer efficiency cores. // -// 我们给数据路径线程用 USER_INTERACTIVE,给统计/日志之类的辅助线程用 UTILITY。 +// We use USER_INTERACTIVE for data-path threads and UTILITY for auxiliary threads such +// as statistics and log flushing. // -// 关于实时线程(THREAD_TIME_CONSTRAINT_POLICY): -// Core Audio 那套 time-constraint 策略能拿到更强的时延保证,但它要求线程 -// 在声明的时间预算内主动让出,超时会被降级惩罚。我们的 BPF read() 是阻塞 -// 调用,时长不可预测,不满足 time-constraint 的使用前提,因此不采用。 +// About real-time threads (THREAD_TIME_CONSTRAINT_POLICY): +// The Core Audio time-constraint policy can provide stronger latency guarantees, but +// it requires the thread to yield voluntarily within its declared time budget; going +// over budget causes a demotion penalty. Our BPF read() is a blocking call whose +// duration is unpredictable, so it does not meet the preconditions for +// time-constraint use and is not adopted. #pragma once #include @@ -25,19 +30,24 @@ namespace tetherkit { -/// 线程用途,决定 QoS 等级。 +/// Thread purpose, which determines the QoS level. enum class ThreadRole : std::uint8_t { - kDataPath, ///< USB 事件循环、BPF 读写 —— 吞吐与时延关键,争取性能核。 - kControl, ///< RNDIS 控制通道、保活 —— 低频但需要及时响应。 - kAuxiliary, ///< 统计报告、日志刷盘 —— 可以让路。 + kDataPath, ///< USB event loop, BPF read/write -- throughput and latency critical; + ///< contend for performance cores. + kControl, ///< RNDIS control channel, keep-alive -- low frequency but needs timely + ///< response. + kAuxiliary, ///< Statistics reporting, log flushing -- can yield. }; -/// 给当前线程设置名字与 QoS。应在线程函数最开头调用一次。 +/// Set the name and QoS for the current thread. Should be called once at the very +/// beginning of the thread function. /// -/// 名字同时写入 thread_local(供日志前缀使用)与内核(供 lldb / Instruments)。 +/// The name is written both to thread_local (for the log prefix) and to the kernel (for +/// lldb / Instruments). void ConfigureCurrentThread(std::string_view name, ThreadRole role) noexcept; -/// 把 ThreadRole 映射到 qos_class_t 的数值。单独暴露以便测试与日志打印。 +/// Map ThreadRole to the numeric value of qos_class_t. Exposed separately for testing and +/// log output. [[nodiscard]] unsigned int QosClassFor(ThreadRole role) noexcept; } // namespace tetherkit diff --git a/include/tetherkit/common/spsc_ring.h b/include/tetherkit/common/spsc_ring.h index eddf4c6..66ee223 100644 --- a/include/tetherkit/common/spsc_ring.h +++ b/include/tetherkit/common/spsc_ring.h @@ -1,16 +1,20 @@ -// 单生产者单消费者(SPSC)无锁有界环形队列。 +// Single-producer single-consumer (SPSC) lock-free bounded ring queue. // -// 为什么要自己写而不用现成的 std::queue + mutex: -// 数据路径上每秒要过 25k~80k 个帧,两个方向各一条队列。互斥锁的加解锁在 -// 争用时会走内核 futex,单次代价可达微秒级;SPSC 无锁队列的入队/出队只需 -// 一次 release store / acquire load,约几纳秒。 +// Why write this instead of using std::queue + mutex: +// The data path moves 25k-80k frames per second, one queue per direction. Lock/unlock +// on a contended mutex goes through a kernel futex and can cost microseconds per +// operation; enqueue/dequeue on an SPSC lock-free queue only needs one release store / +// acquire load, about a few nanoseconds. // -// 本文件提供两层: -// * SpscCursor —— 只负责索引推进与内存序,不关心存储形态。 -// * SpscRing —— 定长元素队列,适合小 POD(完成事件、索引、统计快照)。 -// 变长以太帧走 FrameRing(frame_ring.h),它复用同一个 SpscCursor。 +// This file provides two layers: +// * SpscCursor -- only manages index advancement and memory ordering, agnostic to +// storage layout. +// * SpscRing -- fixed-size element queue, suitable for small PODs (completion +// events, indices, statistics snapshots). +// Variable-length Ethernet frames go through FrameRing (frame_ring.h), which reuses the +// same SpscCursor. // -// 正确性由 ThreadSanitizer 验证: +// Correctness is verified with ThreadSanitizer: // cmake -B build-tsan -DTETHERKIT_ENABLE_TSAN=ON && ctest --test-dir build-tsan #pragma once @@ -24,45 +28,53 @@ namespace tetherkit { -/// SPSC 队列的索引游标:管理「写到哪了 / 读到哪了」以及跨线程的内存序。 +/// Index cursor for an SPSC queue: manages "where written / where read" and +/// cross-thread memory ordering. /// -/// 使用自由递增的 64 位计数器而非「留一个空槽」的经典写法: -/// * 队列长度直接是 `write_pos - read_pos`,无需处理回绕比较; -/// * 容量可以被完整利用(经典写法会浪费一个槽); -/// * 64 位计数器以 100 Mpps 计需要 5800 年才回绕,可以当作永不回绕。 +/// Uses freely incrementing 64-bit counters instead of the classic "leave one slot +/// empty" scheme: +/// * Queue length is simply `write_pos - read_pos`, no wraparound comparison needed; +/// * Capacity can be fully utilized (the classic scheme wastes one slot); +/// * A 64-bit counter takes 5800 years to wrap at 100 Mpps, so it can be treated as +/// never wrapping. /// -/// 布局刻意让四个索引各占一条缓存行: -/// * `write_pos_` 由生产者写、消费者读; -/// * `cached_read_pos_` 是生产者私有的 `read_pos_` 副本,用来避免每次入队都去 -/// 读消费者的缓存行(这是本类最重要的优化 —— 只在「看起来满了」时才真正 -/// 去同步一次); -/// * `read_pos_` / `cached_write_pos_` 对称。 -/// 如果把 `write_pos_` 与 `cached_read_pos_` 放在同一条缓存行上,消费者读 -/// `write_pos_` 会把该行降级为 Shared,生产者随后写 `cached_read_pos_` 又要 -/// 重新取回 Exclusive,白白多一轮缓存一致性往返。 +/// The layout deliberately gives each of the four indices its own cache line: +/// * `write_pos_` is written by the producer and read by the consumer; +/// * `cached_read_pos_` is the producer's private copy of `read_pos_`, used to avoid +/// reading the consumer's cache line on every enqueue (this is the most important +/// optimization in this class -- it only synchronizes for real when it "looks full"); +/// * `read_pos_` / `cached_write_pos_` are symmetric. +/// If `write_pos_` and `cached_read_pos_` shared a cache line, the consumer reading +/// `write_pos_` would downgrade that line to Shared, and the producer writing +/// `cached_read_pos_` would then have to take it back to Exclusive, wasting an extra +/// cache-coherence round trip. class SpscCursor { public: - /// `capacity` 必须是 2 的幂且大于 0(用位掩码取模)。 + /// `capacity` must be a power of two and greater than 0 (indexed by bitmask). explicit SpscCursor(std::size_t capacity) noexcept : capacity_(capacity), mask_(capacity - 1) {} [[nodiscard]] std::size_t Capacity() const noexcept { return capacity_; } // --------------------------------------------------------------------------- - // 生产者侧(只允许生产者线程调用) + // Producer side (only the producer thread may call these) // --------------------------------------------------------------------------- - /// 尝试取得「当前写位置 + offset」处槽位的下标。队列满时返回 false。 + /// Try to acquire the index of the slot at "current write position + offset". Returns + /// false when the queue is full. /// - /// `offset` 是相对于**尚未发布**的写位置的偏移,用于批量写: - /// 连续用 offset = 0,1,2,... 取槽位、写入,最后调一次 PublishWrite(n)。 + /// `offset` is relative to the **not-yet-published** write position, used for batch + /// writes: acquire slots with offset = 0,1,2,..., write to them, then call + /// PublishWrite(n) once. /// - /// 这里刻意只读自己的 write_pos_(relaxed,本缓存行归生产者独占), - /// 因此取槽位本身几乎零成本 —— 真正贵的是发布,见 PublishWrite。 + /// This deliberately reads only its own write_pos_ (relaxed, this cache line is + /// producer-exclusive), so acquiring a slot itself is nearly zero-cost -- the real cost + /// is publishing; see PublishWrite. [[nodiscard]] bool TryAcquireWriteAt(std::uint64_t offset, std::size_t& index) noexcept { const std::uint64_t position = write_pos_.load(std::memory_order_relaxed) + offset; if (position - cached_read_pos_ >= capacity_) [[unlikely]] { - // 缓存的读位置过旧,同步一次真实值再判断。acquire 保证:看到消费者 - // 推进后的 read_pos_ 时,消费者对该槽位的读取也已完成,可以安全覆写。 + // The cached read position is stale; synchronize the real value once and check + // again. acquire guarantees: once we see the consumer's advanced read_pos_, the + // consumer's reads of that slot are complete and it is safe to overwrite. cached_read_pos_ = read_pos_.load(std::memory_order_acquire); if (position - cached_read_pos_ >= capacity_) { return false; @@ -72,33 +84,35 @@ class SpscCursor { return true; } - /// 尝试取得下一个可写槽位(等价于 TryAcquireWriteAt(0, index))。 + /// Try to acquire the next writable slot (equivalent to TryAcquireWriteAt(0, index)). [[nodiscard]] bool TryAcquireWrite(std::size_t& index) noexcept { return TryAcquireWriteAt(0, index); } - /// 发布 `count` 个刚写好的槽位,使其对消费者可见。 + /// Publish `count` newly written slots, making them visible to the consumer. /// - /// release 保证:消费者以 acquire 读到新的 write_pos_ 后,一定能看到我们 - /// 对这些槽位内容的全部写入。 + /// release guarantees: after the consumer reads the new write_pos_ with acquire, it is + /// guaranteed to see all of our writes to those slot contents. /// - /// ★ **批量发布是本类最重要的优化。** ★ - /// 这条 release store 会让 write_pos_ 所在的缓存行跨核弹一次,实测成本约 - /// 60 ns。逐条发布时它就是每条目的成本;而一次发布 N 条时被摊薄成 60/N: + /// **Batch publishing is the most important optimization in this class.** + /// This release store bounces the write_pos_ cache line across cores once (measured at + /// about 60 ns). With per-item publishing that is the cost per item; publishing N at + /// once amortizes it to 60/N: /// - /// batch=1 → 约 63 ns/条目 - /// batch=8 → 约 5.5 ns/条目 (约 11 倍) - /// batch=32 → 约 1.9 ns/条目 (约 33 倍) + /// batch=1 -> about 63 ns/item + /// batch=8 -> about 5.5 ns/item (about 11x) + /// batch=32 -> about 1.9 ns/item (about 33x) /// - /// 所以数据路径上**必须**成批发布 —— 用 FrameRing 的 BatchWriter / - /// BatchReader,它们把「取槽位 + 写入」和「发布」分开,只在批次结束时 - /// 做一次原子操作。 + /// So data paths **must** publish in batches -- use FrameRing's BatchWriter / + /// BatchReader, which separate "acquire slot + write" from "publish" and do only one + /// atomic operation at the end of a batch. void PublishWrite(std::uint64_t count = 1) noexcept { write_pos_.store(write_pos_.load(std::memory_order_relaxed) + count, std::memory_order_release); } - /// 生产者视角的剩余可写槽数(可能偏保守,因为用的是缓存的读位置)。 + /// Remaining writable slots from the producer's perspective (may be conservative + /// because it uses the cached read position). [[nodiscard]] std::size_t WritableApprox() noexcept { const std::uint64_t write_pos = write_pos_.load(std::memory_order_relaxed); cached_read_pos_ = read_pos_.load(std::memory_order_acquire); @@ -106,12 +120,14 @@ class SpscCursor { } // --------------------------------------------------------------------------- - // 消费者侧(只允许消费者线程调用) + // Consumer side (only the consumer thread may call these) // --------------------------------------------------------------------------- - /// 尝试取得「当前读位置 + offset」处槽位的下标。队列空时返回 false。 + /// Try to acquire the index of the slot at "current read position + offset". Returns + /// false when the queue is empty. /// - /// 与写侧对称:用 offset = 0,1,2,... 批量取出,最后调一次 PublishRead(n)。 + /// Symmetric with the write side: acquire in batches with offset = 0,1,2,..., then + /// call PublishRead(n) once. [[nodiscard]] bool TryAcquireReadAt(std::uint64_t offset, std::size_t& index) noexcept { const std::uint64_t position = read_pos_.load(std::memory_order_relaxed) + offset; if (position >= cached_write_pos_) [[unlikely]] { @@ -124,20 +140,23 @@ class SpscCursor { return true; } - /// 尝试取得下一个可读槽位(等价于 TryAcquireReadAt(0, index))。 + /// Try to acquire the next readable slot (equivalent to TryAcquireReadAt(0, index)). [[nodiscard]] bool TryAcquireRead(std::size_t& index) noexcept { return TryAcquireReadAt(0, index); } - /// 释放 `count` 个刚读完的槽位,使其可被生产者复用。 + /// Release `count` slots that have just been read, making them reusable by the + /// producer. /// - /// 与 PublishWrite 同理,批量释放能把跨核缓存行弹跳的成本摊薄 N 倍。 + /// Same as PublishWrite: batch releasing amortizes the cross-core cache-line bounce + /// cost by a factor of N. void PublishRead(std::uint64_t count = 1) noexcept { read_pos_.store(read_pos_.load(std::memory_order_relaxed) + count, std::memory_order_release); } - /// 消费者视角的可读槽数。批量出队时用它一次拿到批大小,减少同步次数。 + /// Readable slots from the consumer's perspective. When dequeuing in batches, use this + /// to get the batch size once and reduce synchronization. [[nodiscard]] std::size_t ReadableApprox() noexcept { const std::uint64_t read_pos = read_pos_.load(std::memory_order_relaxed); cached_write_pos_ = write_pos_.load(std::memory_order_acquire); @@ -145,28 +164,30 @@ class SpscCursor { } // --------------------------------------------------------------------------- - // 任意线程可调用(仅用于统计/观测,结果是瞬时快照) + // Callable from any thread (statistics/observation only; results are point-in-time + // snapshots) // --------------------------------------------------------------------------- - /// 队列中当前元素个数的近似值。 + /// Approximate number of elements currently in the queue. [[nodiscard]] std::size_t SizeSnapshot() const noexcept { const std::uint64_t write_pos = write_pos_.load(std::memory_order_acquire); const std::uint64_t read_pos = read_pos_.load(std::memory_order_acquire); return write_pos >= read_pos ? static_cast(write_pos - read_pos) : 0; } - /// 生产者累计入队总数,用于统计。 + /// Cumulative number of enqueued items (producer), for statistics. [[nodiscard]] std::uint64_t TotalEnqueued() const noexcept { return write_pos_.load(std::memory_order_relaxed); } - /// 消费者累计出队总数,用于统计。 + /// Cumulative number of dequeued items (consumer), for statistics. [[nodiscard]] std::uint64_t TotalDequeued() const noexcept { return read_pos_.load(std::memory_order_relaxed); } private: - // 只读共享数据独占一行,避免与频繁改动的索引挤在一起。 + // Read-only shared data gets its own cache line to avoid sharing with frequently + // modified indices. TETHERKIT_CACHE_ALIGNED const std::size_t capacity_; const std::size_t mask_; @@ -176,14 +197,15 @@ class SpscCursor { TETHERKIT_CACHE_ALIGNED std::uint64_t cached_write_pos_{0}; }; -/// 定长元素的 SPSC 无锁有界队列。 +/// SPSC lock-free bounded queue of fixed-size elements. /// -/// 元素类型要求可平凡复制(POD):数据路径上不接受可能分配内存或抛异常的类型。 +/// The element type must be trivially copyable (POD): types that may allocate memory or +/// throw are not accepted on data paths. template requires std::is_trivially_copyable_v && std::is_default_constructible_v class SpscRing { public: - /// `capacity` 会被向上取整到 2 的幂。 + /// `capacity` is rounded up to a power of two. explicit SpscRing(std::size_t capacity) : cursor_(RoundUpCapacity(capacity)), slots_(std::make_unique(RoundUpCapacity(capacity))) {} @@ -196,7 +218,8 @@ class SpscRing { [[nodiscard]] std::size_t Capacity() const noexcept { return cursor_.Capacity(); } - /// 入队。队列满返回 false(调用方决定丢弃还是重试)。 + /// Enqueue. Returns false when the queue is full (the caller decides whether to drop + /// or retry). [[nodiscard]] bool TryPush(const T& value) noexcept { std::size_t index = 0; if (!cursor_.TryAcquireWrite(index)) { @@ -207,7 +230,7 @@ class SpscRing { return true; } - /// 出队。队列空返回 false。 + /// Dequeue. Returns false when the queue is empty. [[nodiscard]] bool TryPop(T& out) noexcept { std::size_t index = 0; if (!cursor_.TryAcquireRead(index)) { diff --git a/include/tetherkit/common/stats.h b/include/tetherkit/common/stats.h index 870e6ea..e014c58 100644 --- a/include/tetherkit/common/stats.h +++ b/include/tetherkit/common/stats.h @@ -1,15 +1,16 @@ -// 数据路径统计计数器。 +// Data-path statistics counters. // -// 设计要点: -// * 热路径上的计数器**不能**是共享的 std::atomic —— 两个线程对同一个原子做 -// fetch_add,在 25k~80k pps 下会把那条缓存行打成乒乓球。 -// * 因此采用「每方向一份私有计数器 + 读取时快照」的结构:写方是单线程, -// 用 relaxed store 更新(编译后就是一条普通 str 指令);观测线程用 -// relaxed load 读快照,允许读到轻微不一致的一组数值 —— 统计不需要 -// 强一致性。 -// * 用 std::atomic + relaxed 而非裸 uint64_t,是为了让 -// ThreadSanitizer 不把「观测线程读、工作线程写」判成数据竞争。 -// relaxed 原子在 arm64 上就是普通的 ldr/str,无额外开销。 +// Design notes: +// * Counters on hot paths **must not** be shared std::atomic -- two threads doing +// fetch_add on the same atomic at 25k-80k pps turn that cache line into a ping-pong +// ball. +// * So the structure is "one private counter set per direction + snapshot on read": the +// writer is single-threaded and updates with relaxed stores (a plain str instruction +// after compilation); the observer reads snapshots with relaxed loads, tolerating a +// slightly inconsistent set of values -- statistics do not need strong consistency. +// * std::atomic + relaxed is used instead of a plain uint64_t so that +// ThreadSanitizer does not flag "observer reads, worker writes" as a data race. +// Relaxed atomics on arm64 are ordinary ldr/str instructions with no extra cost. #pragma once #include @@ -20,19 +21,26 @@ namespace tetherkit { -/// 单向(RX 或 TX)的数据路径计数器。 +/// Data-path counters for one direction (RX or TX). /// -/// 只允许**一个**线程调用 Add* 方法;任意线程可以调用 Snapshot()。 +/// Only **one** thread may call the Add* methods; any thread may call Snapshot(). struct alignas(kCacheLineSize) DirectionCounters { - std::atomic frames{0}; ///< 成功搬运的帧数。 - std::atomic bytes{0}; ///< 成功搬运的字节数(以太帧净荷)。 - std::atomic dropped_full{0}; ///< 因下游队列满而丢弃的帧数。 - std::atomic dropped_oversize{0}; ///< 因超过单帧上限而丢弃的帧数。 - std::atomic dropped_malformed{0}; ///< 因格式非法而丢弃的帧数。 - std::atomic io_errors{0}; ///< 底层 I/O 失败次数(write/传输错误)。 - std::atomic batches{0}; ///< 批次数,用来算平均批大小。 - - /// 记录一帧成功搬运。这是最热的一行代码,刻意保持只有两次 relaxed 累加。 + std::atomic frames{0}; ///< Number of frames moved successfully. + std::atomic bytes{0}; ///< Bytes moved successfully (Ethernet + ///< frame payload). + std::atomic dropped_full{0}; ///< Frames dropped because the downstream + ///< queue was full. + std::atomic dropped_oversize{0}; ///< Frames dropped for exceeding the + ///< per-frame limit. + std::atomic dropped_malformed{0}; ///< Frames dropped for being + ///< malformed. + std::atomic io_errors{0}; ///< Underlying I/O failure count (write / + ///< transfer errors). + std::atomic batches{0}; ///< Batch count, used to compute average + ///< batch size. + + /// Record one successfully moved frame. This is the hottest line of code and is + /// deliberately kept to just two relaxed increments. void AddFrame(std::uint32_t frame_bytes) noexcept { Bump(frames, 1); Bump(bytes, frame_bytes); @@ -53,14 +61,15 @@ struct alignas(kCacheLineSize) DirectionCounters { void AddIoError(std::uint64_t count = 1) noexcept { Bump(io_errors, count); } private: - /// relaxed 读-改-写。因为只有单一写线程,这里不需要 fetch_add 的原子性, - /// load+store 即可,能省掉 arm64 上的 LSE 原子指令(ldadd)。 + /// Relaxed read-modify-write. Because there is only one writer thread, fetch_add + /// atomicity is not needed here; load+store is enough and avoids the LSE atomic + /// instruction (ldadd) on arm64. static void Bump(std::atomic& counter, std::uint64_t delta) noexcept { counter.store(counter.load(std::memory_order_relaxed) + delta, std::memory_order_relaxed); } }; -/// DirectionCounters 的普通(非原子)快照,便于做差值与格式化。 +/// Plain (non-atomic) snapshot of DirectionCounters, convenient for deltas and formatting. struct DirectionSnapshot { std::uint64_t frames = 0; std::uint64_t bytes = 0; @@ -74,7 +83,7 @@ struct DirectionSnapshot { return dropped_full + dropped_oversize + dropped_malformed; } - /// 逐字段相减,得到两次采样之间的增量。 + /// Field-wise subtraction to get the delta between two samples. [[nodiscard]] DirectionSnapshot operator-(const DirectionSnapshot& earlier) const noexcept { return DirectionSnapshot{ .frames = frames - earlier.frames, @@ -100,16 +109,16 @@ struct DirectionSnapshot { }; } -/// 双向数据路径的全部计数器。 +/// All counters for the bidirectional data path. /// -/// RX = 设备 → 主机(USB bulk IN → BPF write) -/// TX = 主机 → 设备(BPF read → USB bulk OUT) +/// RX = device -> host (USB bulk IN -> BPF write) +/// TX = host -> device (BPF read -> USB bulk OUT) struct PathCounters { DirectionCounters rx; DirectionCounters tx; }; -/// 一段时间窗口内的速率,用于周期性报告。 +/// Rates over a time window, used for periodic reporting. struct RateReport { double seconds = 0.0; double rx_pps = 0.0; @@ -122,12 +131,12 @@ struct RateReport { double tx_avg_batch = 0.0; }; -/// 按固定周期把计数器差值换算成速率。 +/// Convert counter deltas into rates over a fixed period. class RateSampler { public: RateSampler() : last_nanos_(MonotonicNanos()) {} - /// 采样一次,返回自上次采样以来的速率。 + /// Sample once and return the rates since the previous sample. [[nodiscard]] RateReport Sample(const PathCounters& counters) noexcept { const Nanos now = MonotonicNanos(); const DirectionSnapshot rx_now = Snapshot(counters.rx); diff --git a/include/tetherkit/common/time.h b/include/tetherkit/common/time.h index 8ac3714..bc55d18 100644 --- a/include/tetherkit/common/time.h +++ b/include/tetherkit/common/time.h @@ -1,10 +1,11 @@ -// 单调时钟与计时工具。 +// Monotonic clock and timing utilities. // -// 为什么不直接用 std::chrono::steady_clock: -// libc++ 的 steady_clock 在 Darwin 上走 clock_gettime(CLOCK_MONOTONIC), -// 而 clock_gettime_nsec_np(CLOCK_UPTIME_RAW) 更便宜(直接读 mach 绝对时间, -// 不做 timespec 结构体往返,也不受 NTP 调整影响)。基准与保活定时器每秒会调 -// 用几万次,这个差别值得。 +// Why not use std::chrono::steady_clock directly: +// libc++'s steady_clock on Darwin goes through clock_gettime(CLOCK_MONOTONIC), +// while clock_gettime_nsec_np(CLOCK_UPTIME_RAW) is cheaper (reads mach absolute time +// directly, with no timespec struct round-trip and no NTP adjustment). Benchmarks and +// keep-alive timers call this tens of thousands of times per second, so the difference +// matters. #pragma once #include @@ -12,26 +13,28 @@ namespace tetherkit { -/// 纳秒时间戳类型别名,让接口签名自解释。 +/// Nanosecond timestamp type alias, making interface signatures self-explanatory. using Nanos = std::uint64_t; inline constexpr Nanos kNanosPerMicro = 1'000; inline constexpr Nanos kNanosPerMilli = 1'000'000; inline constexpr Nanos kNanosPerSecond = 1'000'000'000; -/// 自系统启动以来的单调纳秒数。不受系统时间调整影响,不在睡眠时停止。 +/// Monotonic nanoseconds since system boot. Not affected by system time adjustments and +/// does not stop during sleep. /// -/// CLOCK_UPTIME_RAW 是 Darwin 上最便宜的时间源:无 NTP 校正、无结构体转换。 +/// CLOCK_UPTIME_RAW is the cheapest time source on Darwin: no NTP correction, no struct +/// conversion. [[nodiscard]] inline Nanos MonotonicNanos() noexcept { return ::clock_gettime_nsec_np(CLOCK_UPTIME_RAW); } -/// 秒表:构造即开始计时。 +/// Stopwatch: starts timing on construction. class Stopwatch { public: Stopwatch() noexcept : start_(MonotonicNanos()) {} - /// 重新开始计时。 + /// Restart timing. void Reset() noexcept { start_ = MonotonicNanos(); } [[nodiscard]] Nanos Elapsed() const noexcept { return MonotonicNanos() - start_; } @@ -48,27 +51,33 @@ class Stopwatch { Nanos start_; }; -/// 单调递增的周期性触发判定器,用于「每 N 毫秒做一次保活 / 统计报告」。 +/// Monotonically increasing periodic trigger, used for "do keep-alive / statistics +/// reporting every N milliseconds". /// -/// 刻意不用定时器线程或 kqueue 定时器:调用方本来就在事件循环里定期醒来, -/// 只需要一个便宜的「时候到了吗」判断。 +/// Deliberately avoids a timer thread or kqueue timer: the caller is already waking up +/// periodically in an event loop and only needs a cheap "is it time yet?" check. class PeriodicTimer { public: - /// @param period 触发周期。 - /// @param start 计时起点。**显式传入而不是内部读时钟**,这样: - /// ① 单元测试可以注入确定的时间基准(否则构造函数自己读到的 - /// 时刻会比调用方手里的 `now` 略晚,导致「now + period」 - /// 反而还没到期,测试结果不可复现); - /// ② 调用方在一个循环里创建多个定时器时能共用同一个时间基准。 - /// 默认值保留「构造即开始计时」的便利写法。 + /// @param period Trigger period. + /// @param start Time base. **Passed in explicitly instead of reading the clock + /// internally**, so that: + /// 1. Unit tests can inject a deterministic time base (otherwise the + /// constructor reads a time slightly later than the caller's `now`, + /// so "now + period" is not yet expired and test results are not + /// reproducible); + /// 2. Callers that create multiple timers in one loop can share the + /// same time base. + /// The default preserves the convenience of "start timing on + /// construction". explicit PeriodicTimer(Nanos period, Nanos start = MonotonicNanos()) noexcept : period_(period), next_deadline_(start + period) {} - /// 到期则返回 true 并把下一次期限推进一个周期。 + /// Return true if expired and advance the next deadline by one period. /// - /// 采用「累加期限」而非「重置为 now + period」,避免调用方被调度延迟时 - /// 定时器整体漂移。若已落后超过一整个周期,直接对齐到当前时刻之后, - /// 避免醒来后连续补发一大串过期触发。 + /// Uses "accumulate the deadline" rather than "reset to now + period" to avoid overall + /// timer drift when the caller is delayed by scheduling. If it has fallen behind by + /// more than one full period, align to just after the current time to avoid firing a + /// burst of overdue triggers on wakeup. [[nodiscard]] bool Expired(Nanos now) noexcept { if (now < next_deadline_) { return false; @@ -82,7 +91,8 @@ class PeriodicTimer { [[nodiscard]] bool Expired() noexcept { return Expired(MonotonicNanos()); } - /// 距下次到期还剩多少纳秒;已到期返回 0。供事件循环计算 select/kevent 超时。 + /// Nanoseconds remaining until the next expiry; returns 0 if already expired. For event + /// loops to compute select/kevent timeouts. [[nodiscard]] Nanos RemainingNanos(Nanos now) const noexcept { return now >= next_deadline_ ? 0 : next_deadline_ - now; } diff --git a/include/tetherkit/core/bridge.h b/include/tetherkit/core/bridge.h index 73eb9ae..b6adad6 100644 --- a/include/tetherkit/core/bridge.h +++ b/include/tetherkit/core/bridge.h @@ -1,48 +1,57 @@ -// 数据路径桥接:把 RNDIS 数据通道与 feth/BPF 链路对接起来。 +// Data path bridge: connects the RNDIS data channel to the feth/BPF link. // -// ★ 线程模型(本文件最重要的设计决定)★ +// Threading model (the most important design decision in this file) // -// ┌─────────────────────────────────────────────────────────────────────┐ -// │ RX 方向(设备 → 主机) │ -// │ │ -// │ libusb 事件线程 RX 注入线程 │ -// │ ─────────────── ─────────── │ -// │ handle_events() BatchRead 取一批帧 │ -// │ └→ bulk IN 回调 ┌──► └→ LinkBackend::WriteFrames │ -// │ ├ 拆 RNDIS 包 │ (一次系统调用发整批) │ -// │ ├ BatchWrite 进 FrameRing ──┘ │ -// │ └ 立刻 resubmit │ -// └─────────────────────────────────────────────────────────────────────┘ -// ┌─────────────────────────────────────────────────────────────────────┐ -// │ TX 方向(主机 → 设备) │ -// │ │ -// │ TX 抽取线程 │ -// │ ─────────── │ -// │ LinkBackend::ReadFrames()(阻塞,内核自动聚合成批) │ -// │ └→ DataChannel::SendFrames(聚合成 RNDIS 多包,异步提交 bulk OUT) │ -// └─────────────────────────────────────────────────────────────────────┘ +// +---------------------------------------------------------------------+ +// | RX direction (device -> host) | +// | | +// | libusb event thread RX injector thread | +// | --------------- ----------- | +// | handle_events() BatchRead takes a batch | +// | +> bulk IN callback +--> +> LinkBackend::WriteFrames | +// | + decode RNDIS packet | (one syscall per batch) | +// | + BatchWrite into FrameRing -+ | +// | + resubmit immediately | +// +---------------------------------------------------------------------+ +// +---------------------------------------------------------------------+ +// | TX direction (host -> device) | +// | | +// | TX extractor thread | +// | ----------- | +// | LinkBackend::ReadFrames() (blocks, the kernel aggregates a batch) | +// | +> DataChannel::SendFrames (aggregates into RNDIS multi-packets, | +// | submits bulk OUT asynchronously) | +// +---------------------------------------------------------------------+ // -// 一共三个数据路径线程(外加 libusb 自己的 IOKit runloop 线程与状态机的控制 -// 线程)。本机是 4 性能核 + 6 效率核,三个热线程 + libusb 的 runloop 线程刚好 -// 占满性能核集群且不过订阅。 +// Three data path threads in total (plus libusb's own IOKit runloop thread +// and the state machine's control thread). This machine has 4 performance +// cores and 6 efficiency cores, so the three hot threads plus libusb's +// runloop thread just fill the performance core cluster without +// oversubscribing it. // -// 为什么 RX 必须拆成两个线程? -// libusb 的 transfer 回调持有 ctx->event_waiters_lock,回调里做阻塞 I/O 会 -// 把所有等同步传输的线程(包括跑控制通道的状态机线程)一起拖住。而 BPF 的 -// write() 是系统调用(macOS 上约 660 ns 起,批量写更久)。所以回调只做 -// 「拆包 + memcpy 进无锁队列 + 立刻 resubmit」,真正的写出交给注入线程。 +// Why must RX be split into two threads? +// libusb's transfer callback holds ctx->event_waiters_lock, so blocking +// I/O inside the callback holds up every thread waiting for a synchronous +// transfer (including the state machine thread that runs the control +// channel). And the BPF write() is a system call (about 660 ns on macOS, +// longer for a batch write). So the callback only does "decode the packet +// + memcpy into the lock-free queue + resubmit immediately"; the actual +// write-out is handed to the injector thread. // -// 为什么 TX 只需要一个线程? -// BPF 的 read() 本身就是阻塞并自动聚合的(BIOCIMMEDIATE=1 下每来一包就唤醒, -// 醒来时一次性交付期间累积的全部包),而 SendFrames 是**异步**提交,不阻塞。 -// 所以「读一批 → 提交一批」在同一个线程里就是最优形态,多一个线程只会多一次 -// 跨核队列传递。 +// Why does TX need only one thread? +// BPF's read() is itself blocking and self-aggregating (with +// BIOCIMMEDIATE=1 it wakes on every arriving packet and delivers all the +// packets accumulated in the meantime in one go), and SendFrames submits +// **asynchronously**, so it does not block. Therefore "read a batch -> +// submit a batch" in a single thread is the optimal shape; one more thread +// would only add one more cross-core queue transfer. // -// 为什么不用单个 kqueue 事件循环把三件事合起来? -// 实测 macOS 上一次空的 kevent()(timeout={0,0}、无事件)要 13.4~13.7 µs, -// 比普通系统调用贵 20 倍。25 kpps 下光探测就要花掉 34% 的单核。kqueue 只能 -// 用于真正的阻塞等待,绝不能用于轮询 —— 而我们的三条路径各自都已经有天然的 -// 阻塞点,合并没有收益。 +// Why not merge all three into a single kqueue event loop? +// Measured on macOS, one empty kevent() (timeout={0,0}, no events) costs +// 13.4~13.7 us, 20 times more than an ordinary system call. At 25 kpps +// the polling alone would eat 34% of a core. kqueue is only for real +// blocking waits, never for polling -- and each of our three paths +// already has a natural blocking point, so merging gains nothing. #pragma once #include @@ -59,58 +68,72 @@ namespace tetherkit::core { -/// 桥接层配置。 +/// Bridge layer configuration. struct BridgeConfig { - /// RX 队列容量(帧数)。 + /// RX queue capacity (in frames). /// - /// 容量推导:要缓冲住「libusb 回调突发」与「BPF 写线程被调度延迟」之间的错配。 - /// 按最坏 10 ms 调度延迟、1 Gbps 满速(82 k pps)计需要约 820 帧;取 2048 留 - /// 2.5 倍余量。每槽 2048+128 字节,2048 槽约 4.25 MiB。 + /// Sizing rationale: it has to absorb the mismatch between "a burst of + /// libusb callbacks" and "the BPF writer being delayed by the scheduler". + /// At a worst-case 10 ms scheduling delay and full 1 Gbps speed (82 k pps) + /// that needs about 820 frames; 2048 leaves a 2.5x margin. Each slot is + /// 2048+128 bytes, so 2048 slots are about 4.25 MiB. std::size_t rx_ring_frames = 2048; - /// 单帧上限(含以太头)。应与 feth 的 MTU + 14 一致。 + /// Max single-frame length (Ethernet header included). Should match the + /// feth MTU + 14. std::uint32_t max_frame_bytes = 1518; - /// RX 注入线程一次最多攒多少帧再写出。 + /// How many frames the RX injector thread accumulates before writing them + /// out. /// - /// 批量写能把「一次 write() 的系统调用成本」摊薄到每帧;实测收益在 batch≥8 - /// 就基本饱和,但更大的批在突发时能进一步降低系统调用次数。取 64 兼顾两者: - /// 空闲时批很小(延迟低),突发时自动变大(吞吐高)。 + /// A batch write amortizes "the system call cost of one write()" over the + /// frames; measured, the gain basically saturates at batch >= 8, but a + /// larger batch further cuts the number of system calls during a burst. 64 + /// balances both: batches stay small when idle (low latency) and grow on + /// their own during a burst (high throughput). std::uint32_t rx_write_batch = 64; - /// RX 队列空时自旋多少次再让出 CPU。 + /// How many times to spin before yielding the CPU when the RX queue is + /// empty. /// - /// 纯自旋会白烧一个核;直接 yield 又会在高吞吐下增加延迟。少量自旋能覆盖 - /// 「生产者正在写下一帧」这种极短的空窗。 + /// Pure spinning burns a core for nothing; yielding outright adds latency + /// at high throughput. A short spin covers very brief gaps such as "the + /// producer is writing the next frame". std::uint32_t rx_spin_before_yield = 64; - /// TX 方向单批最多提交多少帧给 USB。 + /// How many frames a single TX batch submits to the USB. std::uint32_t tx_submit_batch = 256; - /// 统计报告周期(毫秒);0 表示不周期性报告。 + /// Statistics reporting period (milliseconds); 0 means do not report + /// periodically. std::uint32_t stats_interval_millis = 0; }; -/// 桥接层运行时的可观测快照。 +/// An observable snapshot of the bridge layer at runtime. struct BridgeStats { DirectionSnapshot rx; DirectionSnapshot tx; - /// RX 队列当前深度,用于判断哪一侧是瓶颈。 + /// Current RX queue depth, used to tell which side is the bottleneck. std::size_t rx_queue_depth = 0; - /// 内核 BPF 侧累计丢包(bs_drop)。 + /// Cumulative frame drops on the kernel BPF side (bs_drop). std::uint64_t link_kernel_drops = 0; - /// TX 因传输池没有空闲槽位而进入等待的次数。 + /// How many times TX had to wait because the transfer pool had no free + /// slot. /// - /// 这是**事件**计数不是帧数:每次 SendFrames 一帧都没能提交、TX 线程转入 - /// 等待就 +1(持续压力下每约 50 ms 至多再 +1)。它衡量「TX 侧顶到池上限的 - /// 频繁程度」;出现它不代表丢帧 —— 等待期间帧安然留在批次里,上游突发由 - /// 内核 BPF 缓冲吸收,真溢出会体现在 link_kernel_drops。 + /// This counts **events**, not frames: it goes up by 1 every time + /// SendFrames manages to submit not a single frame and the TX thread + /// switches to waiting (under sustained pressure at most one more every + /// ~50 ms). It measures "how often the TX side hits the pool ceiling"; its + /// presence does not mean frames were dropped -- during the wait the frames + /// stay safely in the batch, upstream bursts are absorbed by the kernel BPF + /// buffer, and a real overflow shows up in link_kernel_drops. std::uint64_t tx_backpressure_events = 0; }; -/// 数据路径桥接。 +/// The data path bridge. /// -/// 生命周期:Create → Start → (运行)→ Stop。Stop 是幂等的,析构会兜底调用。 +/// Lifecycle: Create -> Start -> (running) -> Stop. Stop is idempotent and +/// the destructor calls it as a backstop. class Bridge { public: Bridge(usb::DataChannel& data_channel, net::LinkBackend& link, const BridgeConfig& config); @@ -122,12 +145,13 @@ class Bridge { ~Bridge(); - /// 启动数据路径。 + /// Starts the data path. [[nodiscard]] Status Start(); - /// 停止数据路径。 + /// Stops the data path. /// - /// 拆除顺序是刻意安排的,见实现里的注释 —— 顺序错了会漏帧或者死锁。 + /// The teardown order is deliberate; see the comments in the implementation + /// -- a wrong order leaks frames or deadlocks. void Stop(); [[nodiscard]] bool Running() const noexcept { @@ -136,43 +160,53 @@ class Bridge { [[nodiscard]] BridgeStats Snapshot() const; - /// RX 方向的计数器。数据通道的接收回调直接写它。 + /// The RX direction counters. The data channel's receive callback writes + /// them directly. [[nodiscard]] DirectionCounters& RxCounters() noexcept { return counters_.rx; } - /// 暂停 / 恢复数据搬运。 + /// Pauses / resumes the data movement. /// - /// 用于 RNDIS 软复位期间:设备会丢弃所有未完成的数据包,这期间继续往它发帧 - /// 只是浪费,而且可能撞上设备重建内部状态的窗口。 + /// Used during an RNDIS soft reset: the device drops every outstanding + /// packet, so keeping frames coming at it during that window only wastes + /// effort and may collide with the device rebuilding its internal state. /// - /// `SetPaused(true)` 是**同步**的:返回后保证 RX 注入线程已经停在暂停分支上, - /// 既不会再碰 rx_ring_、也不会再往链路写帧。 + /// `SetPaused(true)` is **synchronous**: on return the RX injector thread is + /// guaranteed to be parked in the paused branch, no longer touching rx_ring_ + /// and no longer writing frames to the link. /// - /// 这个同步是必需的,光靠标志位给不了保证:worker 可能刚过完标志检查、正准备 - /// 把一批取出的帧写出去,调用方却以为已经停了。曾经就是这样漏过一个 - /// rx_write_batch(16 帧)出去。 + /// That synchronization is required; a flag alone gives no such guarantee: + /// the worker may have just passed the flag check and be about to write out + /// a batch it already took, while the caller believes it has stopped. That + /// is exactly how one rx_write_batch (16 frames) once leaked out. /// - /// TX 方向拿不到同样强的保证 —— 它必须持续把 BPF 读空,否则内核缓冲会堆满 - /// 转成内核侧丢包。暂停期间它改为读出即丢弃,并在每个提交分片前重新检查, - /// 因此本函数返回后最多只可能有一个已在途的分片继续提交完。 + /// The TX direction cannot get an equally strong guarantee -- it has to + /// keep draining BPF, otherwise the kernel buffer fills up and turns into + /// kernel-side frame drops. While paused it reads and discards instead, and + /// it re-checks before every submission fragment, so after this function + /// returns at most one fragment already in flight can still complete. /// - /// ⚠️ 不可从 RX/TX 工作线程内部调用 —— 会自等死锁。现有调用方都在控制路径上 - /// (链路状态变化、设备软复位),满足这个前提。 + /// Must not be called from inside the RX/TX worker threads -- it would + /// deadlock on itself. The existing callers all sit on the control path + /// (link state changes, device soft reset), which satisfies this premise. void SetPaused(bool paused) noexcept; [[nodiscard]] bool Paused() const noexcept { return paused_.load(std::memory_order_acquire); } private: - /// RX 注入线程:从 FrameRing 取帧批量写入链路。 + /// RX injector thread: takes frames from the FrameRing and writes them to + /// the link in batches. void RunReceiveInjector() noexcept; - /// TX 抽取线程:从链路读帧批量提交给 USB。 + /// TX extractor thread: reads frames from the link and submits them to USB + /// in batches. void RunTransmitExtractor() noexcept; usb::DataChannel* data_channel_; net::LinkBackend* link_; BridgeConfig config_; - /// RX 队列:libusb 事件线程(生产者)→ RX 注入线程(消费者)。 + /// The RX queue: libusb event thread (producer) -> RX injector thread + /// (consumer). std::unique_ptr rx_ring_; PathCounters counters_; @@ -186,18 +220,22 @@ class Bridge { std::atomic stop_requested_{false}; std::atomic paused_{false}; - /// RX 注入线程对 paused_ 的确认位,SetPaused(true) 等的就是它。 + /// The RX injector thread's acknowledgement bit for paused_; SetPaused(true) + /// waits for exactly this. /// - /// 由 RX 注入线程**独占写入**:每轮循环开头先清零、确认停住后再置位。 - /// 每轮都清零是关键 —— 否则 SetPaused(false) 紧接 SetPaused(true) 时, - /// 后者可能读到上一轮残留的 true 就直接返回,保证当场失效。 + /// Written by the RX injector thread **exclusively**: cleared at the start + /// of every loop turn and set once the thread has parked. Clearing it every + /// turn is the key -- otherwise a SetPaused(false) immediately followed by + /// SetPaused(true) could read a leftover true from the previous turn and + /// return at once, voiding the guarantee right there. std::atomic rx_paused_ack_{false}; - /// RX 注入线程复用的帧视图数组,避免每批都分配。 + /// The frame view array reused by the RX injector thread, so no batch has + /// to allocate. std::vector rx_batch_; }; -/// 把统计快照渲染成一行人类可读的文本。 +/// Renders a statistics snapshot as one human-readable line. [[nodiscard]] std::string FormatStatsLine(const BridgeStats& previous, const BridgeStats& current, double seconds); diff --git a/include/tetherkit/core/runtime.h b/include/tetherkit/core/runtime.h index fb06c45..53ec967 100644 --- a/include/tetherkit/core/runtime.h +++ b/include/tetherkit/core/runtime.h @@ -1,28 +1,38 @@ -// 运行时编排:把 USB、RNDIS 状态机、feth、BPF、桥接组装成一个可运行的驱动。 +// Runtime orchestration: assembles USB, the RNDIS state machine, feth, BPF, +// and the bridge into a working driver. // -// ★ 启动顺序(每一步都依赖前一步的产物,不能调换)★ +// Start sequence (each step depends on the previous step's products; the +// order cannot be swapped) // -// 1. 权限检查 —— 提前给出人话错误,而不是让后面一堆 EPERM 冒出来 -// 2. libusb 上下文 + 事件线程 -// 3. 发现并打开 RNDIS 设备(声明两个接口、解析端点) -// 4. RNDIS 控制通道 + 状态机 Start() -// └→ 这一步才拿到:设备 MAC、协商出的 MTU、聚合上限、对齐要求 -// 5. 用第 4 步的结果创建 feth 网卡对(系统侧 MAC = 设备汇报的 MAC) -// 6. 在驱动侧接口上打开 BPF -// 7. 用第 4 步的协商参数创建 USB 数据通道 -// 8. 启动桥接层 -// 9. 控制线程进入循环:定期 Poll()(保活 + 排空设备推送) +// 1. Permission check -- give a plain-language error early instead of +// letting a pile of EPERM surface later +// 2. libusb context + event thread +// 3. Find and open the RNDIS device (claim two interfaces, parse endpoints) +// 4. RNDIS control channel + state machine Start() +// -- this is the step that yields: device MAC, negotiated MTU, +// aggregation limits, alignment requirements +// 5. Create the feth interface pair from step 4's results +// (system-side MAC = the MAC the device reported) +// 6. Open BPF on the driver-side interface +// 7. Create the USB data channel with step 4's negotiated parameters +// 8. Start the bridge +// 9. Control thread enters its loop: periodic Poll() (keep-alive + drain +// device-pushed notifications) // -// 为什么 feth 必须在 RNDIS 协商**之后**创建:系统侧网卡的 MAC 要设成设备 -// 汇报的 OID_802_3_PERMANENT_ADDRESS,而 MTU 要用协商结果 —— 这两个值在第 4 步 -// 之前都拿不到。而 MAC 又必须在接口 IFF_UP 之前设好。 +// Why feth must be created **after** RNDIS negotiation: the system-side +// interface's MAC has to be set to the device-reported +// OID_802_3_PERMANENT_ADDRESS and its MTU has to use the negotiated value -- +// neither is available before step 4. And the MAC must be set before the +// interface goes IFF_UP. // -// ★ 停机顺序是启动顺序的严格逆序 ★ +// Teardown order is the strict reverse of the start sequence // -// 桥接层 → 数据通道(等在飞传输回收)→ BPF → feth → 状态机 HALT → 设备 → libusb +// bridge -> data channel (wait for in-flight transfers to reap) -> BPF -> +// feth -> state machine HALT -> device -> libusb // -// 其中「数据通道等在飞传输回收」这一步必须在销毁设备**之前**完成, -// 否则就是 use-after-free(详见 usb/data_channel.h 的说明)。 +// In particular, "data channel waits for in-flight transfers to reap" must +// complete **before** the device is destroyed, otherwise it is a +// use-after-free (see usb/data_channel.h for details). #pragma once #include @@ -45,31 +55,37 @@ namespace tetherkit::core { -/// 运行时的生命周期状态。这是宿主(命令行 / GUI)唯一需要关心的状态维度。 +/// Lifecycle state of the runtime. The only state dimension the host +/// (command line / GUI) needs to care about. enum class RunState : std::uint8_t { - kIdle, ///< 已创建,尚未 Start()。 - kStarting, ///< 控制线程正在走启动序列(枚举 → 握手 → 建网卡 → 开桥接)。 - kRunning, ///< 数据路径已跑起来。 - kStopping, ///< 正在按逆序拆除。 - kStopped, ///< 已正常停机。 - kFailed, ///< 启动失败或运行中遇到不可恢复错误,原因见 RuntimeSnapshot::fatal_message。 + kIdle, ///< Created but Start() not yet called. + kStarting, ///< Control thread running the start sequence (enumerate -> + ///< handshake -> create interface -> open bridge). + kRunning, ///< Data paths are up. + kStopping, ///< Tearing down in reverse order. + kStopped, ///< Shut down normally. + kFailed, ///< Start failed or an unrecoverable runtime error; see + ///< RuntimeSnapshot::fatal_message. }; [[nodiscard]] std::string_view RunStateName(RunState state) noexcept; -/// 运行时对外通报的一条事件。 +/// One event reported to the host by the runtime. /// -/// 用「事件 + 宿主自己排队」而不是让宿主直接实现 StateMachineObserver: -/// 后者的五个回调语义各异、参数是 C++ 引用,跨语言宿主没法用;而且宿主在回调里 -/// 很容易不小心调回运行时(比如「收到致命错误就停机」),那是自等死锁。 +/// Uses "events + host queues them itself" rather than letting the host +/// implement StateMachineObserver directly: that interface's five callbacks +/// have unrelated semantics and C++ reference parameters, which cross-language +/// hosts cannot use; and in a callback it is all too easy to call back into +/// the runtime by accident (e.g. "stop on fatal error"), which deadlocks +/// waiting for itself. struct RuntimeEvent { enum class Kind : std::uint8_t { - kRndisState, ///< a = 迁移前 rndis::State,b = 迁移后。 - kNegotiated, ///< a = 最终 MTU,b = 链路速率(Mbps)。 - kLink, ///< a = 1 表示链路已连接。 - kDeviceReset, ///< a = 1 表示寻址信息丢失、已重放。 - kFatal, ///< text = 不可恢复错误的原因。 - kRunState, ///< a = 迁移前 RunState,b = 迁移后。 + kRndisState, ///< a = rndis::State before the transition, b = after. + kNegotiated, ///< a = final MTU, b = link speed (Mbps). + kLink, ///< a = 1 means the link is connected. + kDeviceReset, ///< a = 1 means addressing information was lost and replayed. + kFatal, ///< text = the reason for an unrecoverable error. + kRunState, ///< a = RunState before the transition, b = after. }; Kind kind = Kind::kRunState; @@ -78,13 +94,16 @@ struct RuntimeEvent { std::string text; }; -/// 事件汇:宿主实现它来接收运行时事件。 +/// Event sink: implemented by the host to receive runtime events. /// -/// ★ 两条硬约束 ★ -/// 1. 回调**永远在控制线程上**发生(所有事件源都在那条线程),但宿主自己的 -/// 线程可能同时在调 Snapshot() —— 实现里该加的锁一个都不能省。 -/// 2. **实现里绝不能调用 Runtime 的任何方法。** Stop() 要 join 控制线程, -/// 从事件里调它就是自等死锁。正确做法是把事件排进队列,由宿主线程处理。 +/// Two hard constraints +/// 1. Callbacks fire **on the control thread** (all event sources live on +/// that thread), while the host's own threads may be calling Snapshot() +/// at the same time -- every lock the implementation needs must be there. +/// 2. **An implementation must never call any Runtime method.** Stop() +/// joins the control thread; calling it from an event is a self-deadlock. +/// The right approach is to queue events and process them on a host +/// thread. class RuntimeEventSink { public: RuntimeEventSink() = default; @@ -97,51 +116,60 @@ class RuntimeEventSink { virtual void OnRuntimeEvent(const RuntimeEvent& event) = 0; }; -/// 运行时的完整可观测快照。 +/// The runtime's fully observable snapshot. /// -/// 为什么整块拷贝而不是提供一堆逐项访问器:宿主需要的是**一致的**一组数值 -/// (状态与网卡名不匹配的界面很难看懂),逐项读会读到撕裂的组合。一次拷贝 -/// 几百字节,对 2 Hz 的刷新频率完全不是问题。 +/// Why a single whole-struct copy instead of a pile of per-field accessors: +/// the host needs a **consistent** set of values (a UI whose state and +/// interface names do not match is hard to understand), and per-field reads +/// can observe torn combinations. Copying a few hundred bytes at once is +/// no problem at a 2 Hz refresh rate. struct RuntimeSnapshot { RunState run_state = RunState::kIdle; rndis::State rndis_state = rndis::State::kUninitialized; bool link_up = false; - /// 数据搬运是否处于暂停(链路 down 或设备软复位期间)。 + /// Whether data movement is paused (link down or during a device soft reset). bool paused = false; - /// 系统侧网卡名(主机在这张上配 IP)。未创建时为空。 + /// System-side interface name (the one the host configures IP on). Empty + /// until created. std::string system_interface; - /// 驱动侧网卡名(BPF 挂在这张上)。仅用于排障展示。 + /// Driver-side interface name (BPF attaches to this one). For diagnostics + /// display only. std::string driver_interface; - /// 形如 "Bus 020 Device 003: 18d1:4ee4"。未打开设备时为空。 + /// Like "Bus 020 Device 003: 18d1:4ee4". Empty when no device is open. std::string device_description; rndis::DeviceInfo device_info; rndis::NegotiatedParameters parameters; BridgeStats bridge; - /// run_state == kFailed 时的原因;否则为空。 + /// The reason when run_state == kFailed; empty otherwise. std::string fatal_message; }; -/// 运行时配置。默认值都在各自字段的注释里说明了推导依据。 +/// Runtime configuration. Each field's comment explains how its default was +/// derived. struct RuntimeConfig { - /// 设备筛选。全为 0 表示用第一个找到的 RNDIS 设备。 + /// Device filter. All-zero means use the first RNDIS device found. usb::DeviceFilter device_filter; - /// 希望使用的 MTU。设备装不下时会被协商下调。 + /// Desired MTU. Negotiated down if the device cannot handle it. /// - /// 上限受 feth 的 sysctl net.link.fake.max_mtu 约束(本机 2048)。 + /// The cap is bounded by feth's sysctl net.link.fake.max_mtu (2048 on this + /// machine). std::uint32_t mtu = rndis::kDefaultMtu; - /// 指定 feth 接口名(如 "feth7");留空表示让内核自动选编号。 + /// Explicit feth interface names (e.g. "feth7"); empty means let the kernel + /// pick the numbers. std::string system_interface_name; std::string driver_interface_name; - /// 是否把系统侧网卡的 MAC 设为设备汇报的地址。 + /// Whether to set the system-side interface's MAC to the device-reported + /// address. /// - /// 默认开:RNDIS 语义下设备就是这块网卡,对端的 ARP 表与 DHCP 租约都按这个 - /// MAC 建立。关掉只在排查 MAC 冲突时有用。 + /// On by default: in RNDIS semantics the device *is* this interface, and the + /// peer's ARP table and DHCP leases are established against that MAC. + /// Turning it off is only useful when debugging MAC conflicts. bool adopt_device_mac = true; rndis::StateMachineConfig rndis; @@ -149,38 +177,48 @@ struct RuntimeConfig { net::BpfConfig bpf; BridgeConfig bridge; - /// 统计报告周期(毫秒);0 表示不报告。 + /// Statistics reporting period (milliseconds); 0 means do not report. std::uint32_t stats_interval_millis = 5'000; - /// 事件汇。为 nullptr 表示不需要事件通报(命令行就不需要,它看日志)。 + /// Event sink. nullptr means event reporting is not needed (the command + /// line does not need it; it reads the logs). /// - /// 生命周期由调用方负责,必须活得比 Runtime 久。 + /// Its lifetime is the caller's responsibility and must outlive the + /// Runtime. RuntimeEventSink* event_sink = nullptr; }; -/// 组装并运行整个驱动。 +/// Assemble and run the whole driver. /// -/// ★ 线程模型:Start() 是**非阻塞**的 ★ +/// Threading model: Start() is **non-blocking** /// -/// Runtime 自己拥有一条控制线程,启动序列、保活循环、停机拆除**全部**在它 -/// 上面跑。宿主线程只负责发号施令与读快照。 +/// The Runtime owns one control thread, and the start sequence, keep-alive +/// loop, and teardown **all** run on it. The host thread only gives orders +/// and reads snapshots. /// -/// 为什么必须这样: -/// * 启动序列包含 USB 握手,慢设备上要几百毫秒到数秒,阻塞 GUI 主线程不行; -/// * 控制通道用 libusb 的同步 API,而同步 API 在 libusb 事件线程上会返回 -/// LIBUSB_ERROR_BUSY —— 由 Runtime 自己建线程,就不用再要求宿主「必须 -/// 从某个特定线程调用」这种极易违反的约定; -/// * 拆除顺序里夹着 RNDIS 的优雅停机(要发控制消息),把它也放在同一条 -/// 线程上,`StateMachine` 的「所有方法同线程调用」约束就自然满足了。 +/// Why it must be this way: +/// * The start sequence contains a USB handshake which can take hundreds +/// of milliseconds to seconds on a slow device; blocking the GUI main +/// thread is unacceptable. +/// * The control channel uses libusb's synchronous API, and the +/// synchronous API returns LIBUSB_ERROR_BUSY on the libusb event +/// thread -- with Runtime owning the thread, we avoid demanding a "must +/// call from this particular thread" convention that is trivial to +/// violate. +/// * Teardown embeds RNDIS's graceful shutdown (which sends control +/// messages); putting it on the same thread satisfies `StateMachine`'s +/// "all methods called from the same thread" constraint naturally. /// -/// 用法: +/// Usage: /// ``` /// TETHERKIT_ASSIGN_OR_RETURN(auto runtime, Runtime::Create(config)); -/// TETHERKIT_RETURN_IF_ERROR(runtime->Start()); // 立刻返回 -/// runtime->WaitUntilStopped(); // 命令行:把主线程挂起 -/// runtime->Stop(); // 幂等 +/// TETHERKIT_RETURN_IF_ERROR(runtime->Start()); // returns immediately +/// runtime->WaitUntilStopped(); // command line: park the +/// // main thread +/// runtime->Stop(); // idempotent /// ``` -/// GUI 则不调 WaitUntilStopped,而是定时 Snapshot() 刷界面。 +/// A GUI does not call WaitUntilStopped; it calls Snapshot() periodically to +/// refresh the UI. class Runtime final : public rndis::StateMachineObserver { public: [[nodiscard]] static Result> Create(const RuntimeConfig& config); @@ -192,34 +230,43 @@ class Runtime final : public rndis::StateMachineObserver { ~Runtime() override; - /// 起控制线程并立刻返回。 + /// Starts the control thread and returns immediately. /// - /// 返回成功只表示「启动请求已受理」,真正的成败要看 Snapshot().run_state。 - /// 唯一会**同步**返回失败的是 root 检查 —— 它不需要 I/O,且提前报出来能让 - /// 命令行给出即时的人话提示。 + /// A successful return only means the "start request was accepted"; the real + /// outcome must be observed via Snapshot().run_state. The only failure that + /// returns **synchronously** is the root check -- it needs no I/O, and + /// reporting it early lets the command line print an immediate + /// plain-language hint. [[nodiscard]] Status Start(); - /// 阻塞直到运行时停止(正常停机或致命错误)。命令行用它把主线程挂起。 + /// Blocks until the runtime stops (graceful shutdown or fatal error). The + /// command line uses it to park the main thread. void WaitUntilStopped(); - /// 请求停机。可从信号处理器或任意线程调用,异步信号安全(只写一个原子)。 + /// Requests shutdown. Callable from a signal handler or any thread; + /// async-signal-safe (writes a single atomic). /// - /// 代价是控制线程最多要过一个循环周期(≤250 ms)才看得到。要立刻停就用 - /// Stop(),它会额外唤醒控制线程 —— 但 Stop() 会加锁,**不是**异步信号安全的。 + /// The cost is that the control thread may need up to one loop period + /// (<=250 ms) to notice. To stop immediately use Stop(), which also wakes + /// the control thread -- but Stop() takes a lock and is **not** + /// async-signal-safe. void RequestStop() noexcept { stop_requested_.store(true, std::memory_order_release); } - /// 请求停机并等控制线程完成全部拆除。幂等。 + /// Requests shutdown and waits for the control thread to finish the full + /// teardown. Idempotent. /// - /// ⚠️ 不可从事件汇(RuntimeEventSink)里调用 —— 那是控制线程自己,会自等死锁。 + /// Must NOT be called from a RuntimeEventSink -- that is the control thread + /// itself; it would deadlock waiting for itself. void Stop(); - /// 取一份一致的状态快照。**任意线程可调**。 + /// Takes a consistent state snapshot. **Callable from any thread.** [[nodiscard]] RuntimeSnapshot Snapshot() const; - /// 系统侧网卡名,用于给用户打印「接下来该做什么」。 + /// System-side interface name, for printing "what to do next" hints to the + /// user. [[nodiscard]] std::string SystemInterfaceName() const; - // ---- StateMachineObserver(均在控制线程上被调用)---- + // ---- StateMachineObserver (all called on the control thread) ---- void OnStateChanged(rndis::State from, rndis::State to) override; void OnNegotiated(const rndis::NegotiatedParameters& parameters, const rndis::DeviceInfo& info) override; @@ -230,39 +277,47 @@ class Runtime final : public rndis::StateMachineObserver { private: Runtime() = default; - /// 控制线程主体:启动序列 → 控制循环 → 拆除。 + /// Control thread body: start sequence -> control loop -> teardown. void RunControlThread() noexcept; - /// 完整的启动序列(原 Start() 的主体)。只在控制线程上跑。 + /// The complete start sequence (formerly the body of Start()). Runs on the + /// control thread only. [[nodiscard]] Status RunStartSequence(); - /// 保活 + 统计 + 停机检查的循环。只在控制线程上跑。 + /// The keep-alive + statistics + stop-check loop. Runs on the control + /// thread only. void RunControlLoop(); - /// 按启动顺序的严格逆序拆除。只在控制线程上跑。 + /// Teardown in the strict reverse of the start order. Runs on the control + /// thread only. void Teardown(); - /// 迁移生命周期状态并通报事件。 + /// Transitions the lifecycle state and reports the event. void SetRunState(RunState next); - /// 记录致命错误:写进快照、置 kFailed 标志、通报事件。 + /// Records a fatal error: write it into the snapshot, set the kFailed flag, + /// report the event. void RecordFatal(const Error& error); - /// 把各组件的当前状况刷进快照。只在控制线程上调用。 + /// Refreshes the snapshot with the current state of every component. Called + /// on the control thread only. void RefreshSnapshot(); - /// 把一条事件交给宿主。sink 为空时是空操作。 + /// Hands one event to the host. A no-op when the sink is null. void Emit(const RuntimeEvent& event) const; - /// 打印「网卡已就绪,接下来该怎么配 IP」的提示。 + /// Prints the "interface is ready, here is how to configure the IP" hint. void PrintNextSteps() const; RuntimeConfig config_; - // ---- 以下组件全部**只由控制线程**创建、访问与销毁 ---- + // ---- Every component below is created, accessed and destroyed by the + // ---- control thread alone ---- // - // 宿主线程一律通过 snapshot_ 读状态,绝不碰这些指针 —— 否则 Stop() 里的 - // reset() 与宿主的读会构成数据竞争,而那种竞争在真机上表现为随机崩溃。 + // The host thread reads state exclusively through snapshot_ and never + // touches these pointers -- otherwise the reset() in Stop() and the host's + // reads form a data race, and such a race shows up on a real device as a + // random crash. std::unique_ptr usb_context_; std::unique_ptr device_; std::unique_ptr control_channel_; @@ -274,19 +329,22 @@ class Runtime final : public rndis::StateMachineObserver { std::thread control_thread_; - /// 保护 snapshot_。读侧是宿主线程(GUI 每 500 ms 一次),写侧是控制线程, - /// 竞争极轻,普通互斥锁足够。 + /// Guards snapshot_. The reader is the host thread (the GUI every 500 ms), + /// the writer is the control thread; the contention is light, so a plain + /// mutex is enough. mutable std::mutex snapshot_mutex_; RuntimeSnapshot snapshot_; - /// 让 Stop() 能立刻唤醒正在睡的控制线程,而不用等满一个循环周期。 + /// Lets Stop() wake the sleeping control thread immediately instead of + /// waiting out a full loop period. std::mutex stop_mutex_; std::condition_variable stop_condition_; std::atomic stop_requested_{false}; std::atomic fatal_error_{false}; - /// 只在宿主线程上读写(Start / Stop / 析构),不需要同步。 + /// Read and written on the host thread only (Start / Stop / destructor), so + /// no synchronization is needed. bool started_ = false; bool stopped_ = false; }; diff --git a/include/tetherkit/net/bpf_link.h b/include/tetherkit/net/bpf_link.h index c514409..9682f08 100644 --- a/include/tetherkit/net/bpf_link.h +++ b/include/tetherkit/net/bpf_link.h @@ -1,49 +1,67 @@ -// BPF 链路后端:在 feth 的驱动侧接口上直接读写原始以太帧。 +// BPF link backend: reads and writes raw Ethernet frames directly on the +// driver-side feth interface. // -// ★ 本文件里每一条 ioctl 顺序与参数选择都有源码级理由,改动前务必读完注释 ★ +// Every ioctl order and parameter choice in this file has a source-level +// rationale; read the comments in full before changing anything. // -// 配置序列(顺序有硬性约束,来自 xnu bsd/net/bpf.c): -// 1. open("/dev/bpf%d") —— macOS **没有** /dev/bpf 克隆节点,必须遍历编号。 -// 2. BIOCSBLEN —— **必须在 BIOCSETIF 之前**(bpf.c 里有 -// `if (d->bd_bif != 0) return EINVAL`)。 -// 3. BIOCSHDRCMPLT = 1 —— **必须在 BIOCSBATCHWRITE 之前**,且它决定帧是否 -// 原样透传(见下)。 -// 4. BIOCSETIF —— 绑定到接口,此后才能查 DLT。 -// 5. BIOCGDLT —— 校验是 DLT_EN10MB。 -// 6. BIOCIMMEDIATE = 1 —— 决定读取的延迟特性(见下)。 -// 7. BIOCSSEESENT = 0 —— 只看 input 方向(见下)。 -// 8. BIOCSBATCHWRITE = 1 —— 可选优化,特性探测。 -// 9. BIOCSNOTSTAMP = 1 —— 可选优化,特性探测。 +// Configuration sequence (the order is hard-constrained, per xnu +// bsd/net/bpf.c): +// 1. open("/dev/bpf%d") -- macOS has **no** /dev/bpf clone node; iterate the +// numbered devices. +// 2. BIOCSBLEN -- **must come before BIOCSETIF** (bpf.c has +// `if (d->bd_bif != 0) return EINVAL`). +// 3. BIOCSHDRCMPLT = 1 -- **must come before BIOCSBATCHWRITE**; it also +// decides whether frames pass through verbatim +// (see below). +// 4. BIOCSETIF -- bind to the interface; only after this can the +// DLT be queried. +// 5. BIOCGDLT -- verify it is DLT_EN10MB. +// 6. BIOCIMMEDIATE = 1 -- decides the latency profile of reads (below). +// 7. BIOCSSEESENT = 0 -- look only at the input direction (below). +// 8. BIOCSBATCHWRITE = 1 -- optional optimization, feature-probed. +// 9. BIOCSNOTSTAMP = 1 -- optional optimization, feature-probed. // -// 三个最关键的参数选择: +// The three most critical parameter choices: // -// * **BIOCSHDRCMPLT = 1 是强制的。** hdrcmplt=0 时 bpfwrite 会把用户数据的 -// 前 14 字节剥进 sockaddr,再让 ether_frameout 重建帧头 —— 源 MAC 会被 -// 驱动改写,转发出去的帧就不是设备发来的原始帧了。hdrcmplt=1 才走 -// DLIL_OUTPUT_FLAGS_RAW,帧原样透传。 +// * **BIOCSHDRCMPLT = 1 is mandatory.** With hdrcmplt=0, bpfwrite peels the +// first 14 bytes of the user data into a sockaddr and lets ether_frameout +// rebuild the frame header -- so the source MAC gets rewritten by the +// driver and the forwarded frame is no longer the original frame from the +// device. Only hdrcmplt=1 takes the DLIL_OUTPUT_FLAGS_RAW path and passes +// the frame through verbatim. // -// * **BIOCSSEESENT = 0 恰好过滤掉我们自己写进去的帧。** 挂在驱动侧接口上时: -// 主机从系统侧发出的帧在这里是 **input** 方向;我们 write 进去的帧在这里是 -// **output** 方向。SEESENT=0 → bd_direction = BPF_D_IN,只留 input, -// 正是我们要转发到 USB 的那些。不设的话会读到自己刚写的帧,形成回环。 +// * **BIOCSSEESENT = 0 exactly filters out the frames we ourselves wrote.** +// When attached to the driver-side interface: frames the host sends from +// the system side show up here as the **input** direction; frames we write +// show up here as **output**. SEESENT=0 -> bd_direction = BPF_D_IN, which +// keeps only input -- exactly the frames we want to forward to USB. +// Without it you read back your own writes and form a loop. // -// * **BIOCIMMEDIATE = 1 + 专用线程阻塞 read() 是最优模型,不要用 kqueue。** -// immediate 下每来一包就 bpf_wakeup,read() 醒来后一次性把期间累积的所有包 -// 整批交付 —— 低速时低延迟、高速时自动聚合成大批(行为类似 Linux 的 NAPI), -// 每批只花一次系统调用。kqueue 方案的就绪判据完全一样,却要多一次 kevent()。 -// 而如果不开 immediate 又想避免「缓冲填满才返回」,就只能依赖 -// BIOCSRTIMEOUT —— 但它的实际分辨率被 10 ms 的时钟 tick 钉死 -// (内核存 tvtohz(tv)-1,本机 hz=100),延迟不可接受。 +// * **BIOCIMMEDIATE = 1 plus a dedicated thread blocking in read() is the +// optimal model; do not use kqueue.** With immediate, every arriving packet +// raises bpf_wakeup, and when read() wakes it delivers all packets +// accumulated in the meantime as one batch -- low latency at low speeds, +// automatic aggregation into large batches at high speeds (behavior like +// Linux NAPI), one syscall per batch. The kqueue approach has exactly the +// same readiness criterion but pays an extra kevent() for it. Without +// immediate, the only way to avoid "return only when the buffer fills" is +// BIOCSRTIMEOUT -- whose real resolution is pinned by the 10 ms clock tick +// (the kernel stores tvtohz(tv)-1; hz=100 on this machine), an +// unacceptable latency. // -// 两个必须记住的读取细节: +// Two read-side details that must be remembered: // -// * **read() 的缓冲区长度必须精确等于 bd_bufsize**,否则 bpfread 开头就 -// `if (uio_resid(uio) != d->bd_bufsize) return EINVAL`。而 BIOCSBLEN 在 -// 超限时**不报错**、静默截到上限并把实际值写回参数,所以必须用写回值。 +// * **read()'s buffer length must be exactly bd_bufsize**, otherwise bpfread +// exits at the top with +// `if (uio_resid(uio) != d->bd_bufsize) return EINVAL`. And BIOCSBLEN does +// **not** report an error when over the limit; it silently clamps to the +// cap and writes the effective value back into the argument, so you must +// use the written-back value. // -// * **遍历记录必须用 bh_hdrlen,不能用 sizeof(struct bpf_hdr)。** -// LP64 下 sizeof 是 20(18 字节内容被补齐),而内核对 DLT_EN10MB 写入的 -// bh_hdrlen 是 18。用 20 会立刻错位。 +// * **Walk records with bh_hdrlen, not sizeof(struct bpf_hdr).** +// On LP64 sizeof is 20 (18 bytes of content, padded), while the kernel +// writes bh_hdrlen = 18 for DLT_EN10MB. Using 20 desyncs the walk +// immediately. #pragma once #include @@ -59,54 +77,64 @@ namespace tetherkit::net { -/// BPF 后端的配置参数。 +/// Configuration parameters for the BPF backend. struct BpfConfig { - /// 内核抓包缓冲区大小。 + /// Kernel packet-capture buffer size. /// - /// 上限是 sysctl debug.bpf_bufsize_cap(macOS 13+ 为 32 MiB),超限会被静默 - /// 截断。内核会为每个描述符分配 **2 份**这么大的缓冲(store + free 双缓冲), - /// 所以实际 wired 内存占用是这个值的两倍。 + /// The cap is sysctl debug.bpf_bufsize_cap (32 MiB on macOS 13+); exceeding + /// it is silently truncated. The kernel allocates **two** buffers of this + /// size per descriptor (a store + free double buffer), so actual wired + /// memory usage is twice this value. /// - /// 默认 4 MiB 的推导:1 Gbps 满速下 1514 字节帧约 82 k pps,一次 copyout - /// 窗口按最坏 10 ms 估算需要缓冲约 1.2 MB;取 4 MiB 留 3 倍余量。再大只是 - /// 浪费 wired 内存,因为 BPF 只有两份缓冲,增大单份并不能改善丢包窗口。 + /// Derivation of the 4 MiB default: at full 1 Gbps, 1514-byte frames are + /// about 82 k pps; one copyout window, worst case 10 ms, needs roughly + /// 1.2 MB of buffering; 4 MiB leaves a 3x margin. Anything bigger merely + /// wastes wired memory, because BPF has only the two buffers and enlarging + /// one does not improve the drop window. std::uint32_t kernel_buffer_bytes = 4U * 1024 * 1024; - /// 单帧长度上限(含以太头)。应等于 接口 MTU + 以太头,不能超过 MTU + 18。 + /// Max single-frame length (Ethernet header included). Should equal + /// interface MTU + Ethernet header and must not exceed MTU + 18. std::uint32_t max_frame_bytes = 1518; - /// 一次 ReadFrames 最多解出多少帧。 + /// Upper bound on how many frames one ReadFrames decodes. /// - /// 这是 FrameView 数组的容量。4 MiB 缓冲里最多能装约 4Mi/32 ≈ 131 k 个最小 - /// 帧(64 字节帧 + 18 字节头 + 对齐),但实际不会全是最小帧;取 8192 覆盖 - /// 绝大多数批次,超出部分留到下一次 read 之后的解析继续处理。 + /// This is the capacity of the FrameView array. A 4 MiB buffer fits at most + /// about 4Mi/32 = 131 k minimum-size frames (64-byte frame + 18-byte header + /// + alignment), but real traffic is never all minimum-size; 8192 covers the + /// vast majority of batches, and anything beyond carries over to the parse + /// pass after the next read. std::size_t max_frames_per_batch = 8192; - /// 是否尝试启用批量写(BIOCSBATCHWRITE,macOS 14+)。 + /// Whether to try enabling batched writes (BIOCSBATCHWRITE, macOS 14+). bool try_batch_write = true; - /// 是否尝试关闭抓包时间戳(BIOCSNOTSTAMP),省掉每帧一次 microtime()。 + /// Whether to try disabling capture timestamps (BIOCSNOTSTAMP), saving one + /// microtime() per frame. bool try_disable_timestamp = true; - /// 读超时。决定「完全没有流量时,Interrupt() 后多久能停下来」。 + /// Read timeout. Determines how quickly Interrupt() can stop the loop when + /// there is no traffic at all. /// - /// 注意内核的实际分辨率是 10 ms(存的是 tvtohz(tv) - 1 个 tick,本机 - /// kern.clockrate hz=100),且传 {0,0} 会变成**永久阻塞**。200 ms 既能让 - /// 停机足够及时,又不会产生无谓的唤醒。 + /// Note the kernel's actual resolution is 10 ms (it stores tvtohz(tv) - 1 + /// ticks; kern.clockrate hz=100 on this machine), and passing {0,0} becomes + /// **a permanent block**. 200 ms is prompt enough for shutdown without + /// needless wakeups. std::uint32_t read_timeout_millis = 200; }; -/// 挂在指定接口上的 BPF 描述符。 +/// A BPF descriptor attached to the given interface. class BpfLink final : public LinkBackend { public: - /// 打开并配置一个 BPF 描述符,绑定到 `interface_name`。 + /// Opens and configures a BPF descriptor bound to `interface_name`. /// - /// 需要 root:/dev/bpf* 的权限是 0600 root:wheel,而 macOS **没有** - /// FreeBSD 那样的 access_bpf 组可以走。 + /// Requires root: /dev/bpf* is 0600 root:wheel, and macOS has **no** + /// access_bpf group like FreeBSD's to lean on. [[nodiscard]] static Result> Open(std::string_view interface_name, const BpfConfig& config); - // 拷贝与移动已在基类 LinkBackend 中删除,这里显式重申以满足静态检查。 + // Copy and move are already deleted in the base LinkBackend; restated here + // to satisfy static checks. BpfLink(const BpfLink&) = delete; BpfLink& operator=(const BpfLink&) = delete; BpfLink(BpfLink&&) = delete; @@ -122,13 +150,14 @@ class BpfLink final : public LinkBackend { void Interrupt() noexcept override; - /// 内核实际生效的缓冲区大小(可能被截断到上限)。 + /// The buffer size actually in effect in the kernel (possibly clamped to the + /// cap). [[nodiscard]] std::uint32_t KernelBufferBytes() const noexcept { return kernel_buffer_bytes_; } - /// 打开的设备节点路径,用于日志。 + /// The opened device-node path, for logging. [[nodiscard]] std::string_view DevicePath() const noexcept { return device_path_; } - /// 内核侧统计:累计收到 / 丢弃的包数。 + /// Kernel-side statistics: cumulative received / dropped packet counts. struct KernelStats { std::uint64_t received = 0; std::uint64_t dropped = 0; @@ -139,10 +168,10 @@ class BpfLink final : public LinkBackend { private: BpfLink() = default; - /// 逐帧 write(),批量写不可用时的回落路径。 + /// Per-frame write(); the fallback path when batched writes are unavailable. [[nodiscard]] Result WriteFramesIndividually(FrameBatch frames); - /// 一次 write() 发多帧(BIOCSBATCHWRITE)。 + /// Multiple frames per write() (BIOCSBATCHWRITE). [[nodiscard]] Result WriteFramesBatched(FrameBatch frames); int fd_ = -1; @@ -154,27 +183,36 @@ class BpfLink final : public LinkBackend { bool batch_write_enabled_ = false; bool timestamp_disabled_ = false; - /// 读缓冲。长度必须精确等于 kernel_buffer_bytes_(bpfread 的硬性要求)。 + /// Read buffer. Length must be exactly kernel_buffer_bytes_ (a hard + /// requirement of bpfread). std::vector read_buffer_; - /// 本次批次解出的帧视图,指向 read_buffer_ 内部。 + /// Frame views decoded for this batch, pointing into read_buffer_. std::vector read_frames_; - /// 最近一次成功读到的内核累计丢包数(bs_drop)。 + /// The last successfully read cumulative kernel drop count (bs_drop). /// - /// BIOCGSTATS 偶发失败时**沿用上次的值**而不是报 0:bs_drop 是累计计数, - /// 消费方拿它做「当前 − 上次」的差分,报 0 会让差分在无符号数上下溢出, - /// 打出一条天文数字的「内核丢包」。只被读线程访问,无需原子。 + /// When BIOCGSTATS fails sporadically, **keep the previous value** rather + /// than reporting 0: bs_drop is a cumulative counter and consumers take + /// "current - previous" differences from it; reporting 0 makes that + /// difference underflow through unsigned arithmetic and print an + /// astronomical "kernel drops" figure. Only accessed by the read thread, so + /// no atomics needed. std::uint64_t last_kernel_drops_ = 0; - /// 批量写的组装缓冲。 + /// Assembly buffer for batched writes. std::vector write_buffer_; - /// 停机标志。Interrupt() 置位后 ReadFrames 会在一个读超时周期内返回空批次。 + /// Shutdown flag. Once Interrupt() sets it, ReadFrames returns an empty + /// batch within one read-timeout period. /// - /// 唤醒机制的选择:BPF 的 read() 是阻塞的,要打断它有三条路 —— - /// (a) 发信号让 BPF_SLEEP(带 PCATCH)返回 EINTR; - /// (b) 关掉 fd(有 use-after-close 竞态,不可取); - /// (c) 设一个适中的 BIOCSRTIMEOUT,让 read() 周期性醒来检查本标志。 - /// 选 (c):不需要在进程里安装信号处理器,也没有竞态。BIOCIMMEDIATE=1 保证 - /// 有包时仍然立即返回,读超时只影响「完全没流量时多久能响应停机」。 + /// Choice of wakeup mechanism: BPF's read() blocks, and breaking it has + /// three possible routes -- + /// (a) send a signal so BPF_SLEEP (with PCATCH) returns EINTR; + /// (b) close the fd (a use-after-close race, unacceptable); + /// (c) set a moderate BIOCSRTIMEOUT so read() wakes periodically and + /// checks this flag. + /// (c) was chosen: no signal handler needs to be installed in the process, + /// and there is no race. BIOCIMMEDIATE=1 keeps reads returning immediately + /// when packets exist; the read timeout only affects how soon shutdown is + /// noticed when there is no traffic at all. std::atomic interrupted_{false}; }; diff --git a/include/tetherkit/net/darwin_abi.h b/include/tetherkit/net/darwin_abi.h index 82bec86..d2b7982 100644 --- a/include/tetherkit/net/darwin_abi.h +++ b/include/tetherkit/net/darwin_abi.h @@ -1,22 +1,27 @@ -// Darwin 私有 ABI 声明。 +// Darwin private ABI declarations. // -// 本文件声明的东西**不在**公开 macOS SDK 里,但驱动必须用到。全部定义都从 -// Apple 开源的 xnu 源码抄来,并在本机用 static_assert + 实测 ioctl 编号核对过。 +// Nothing declared here is in the public macOS SDK, but the driver needs all of +// it. Every definition is copied from Apple's open-source xnu sources and was +// verified on this machine with static_asserts plus measured ioctl numbers. // -// 为什么敢用私有 ABI(以及风险评估): -// * `net/if_fake_var.h` 在 xnu-7195(macOS 11) → xnu-12377(macOS 26) 的所有 -// 发布 tag 下文件内容完全相同(md5 一致),15 年来未变动过一个字节。 -// `ifconfig fethN peer fethM` 走的就是这套 ABI,Apple 自己的 ifconfig -// 依赖它,因此它实际上是被冻结的。 -// * `struct ifdrv` 同理,且它的大小直接参与 _IOW 宏计算 ioctl 编号 —— -// 一旦大小算错,ioctl 号就错,内核会返回 ENOTTY 而不会做危险的事。 -// 下面用 static_assert 把大小钉死为 40。 -// * BPF 的私有 ioctl(BIOCSBATCHWRITE / BIOCSNOTSTAMP)是**可选优化**, -// 一律做运行时特性探测,失败就回落到通用路径,不影响功能正确性。 -// macOS 26 把它们从 net/bpf.h 挪到了 net/bpf_private.h(SDK 未提供该文件)。 +// Why private ABI is acceptable here (risk assessment): +// * `net/if_fake_var.h` is byte-identical (same md5) across every release tag +// from xnu-7195 (macOS 11) to xnu-12377 (macOS 26); not a single byte of +// change in 15 years. `ifconfig fethN peer fethM` uses exactly this ABI and +// Apple's own ifconfig depends on it, so in practice it is frozen. +// * `struct ifdrv` likewise, and its size participates directly in the _IOW +// macro's ioctl-number computation -- get the size wrong and you get a +// wrong ioctl number, where the kernel returns ENOTTY and does nothing +// dangerous. The static_assert below pins the size at 40. +// * The BPF private ioctls (BIOCSBATCHWRITE / BIOCSNOTSTAMP) are **optional +// optimizations**, always feature-probed at runtime; on failure we fall +// back to the generic path, so functional correctness is unaffected. +// macOS 26 moved them from net/bpf.h to net/bpf_private.h (the SDK does +// not provide that header). // -// 一句话:功能性 ABI(feth peer)经过 15 年验证且有 static_assert 兜底; -// 优化性 ABI(BPF 批量写)做特性探测。两者都不会静默走错路径。 +// In short: the functional ABI (feth peer) has 15 years of history plus a +// static_assert backstop; the optimization ABI (BPF batch write) is probed. +// Neither can silently take a wrong path. #pragma once #include @@ -33,73 +38,78 @@ namespace tetherkit::net { // ============================================================================= -// struct ifdrv —— SIOCSDRVSPEC / SIOCGDRVSPEC 的参数 +// struct ifdrv -- parameter struct for SIOCSDRVSPEC / SIOCGDRVSPEC // -// 出处:xnu/bsd/net/if.h(带 #pragma pack(4),但在 LP64/arm64 上不改变布局)。 -// SDK 的 net/if.h 里没有这个结构,只有引用它的 ioctl 宏定义。 +// Source: xnu/bsd/net/if.h (wrapped in #pragma pack(4), which does not change +// the layout on LP64/arm64). The SDK's net/if.h lacks the struct itself and +// only has the ioctl macros that reference it. // ============================================================================= -/// 驱动私有 ioctl 的参数块。 +/// Parameter struct for driver-private ioctls. struct IfDrv { - char ifd_name[IFNAMSIZ]; ///< 目标接口名。 - unsigned long ifd_cmd; ///< 驱动私有命令码(对 feth 是 IF_FAKE_S_CMD_*)。 - std::size_t ifd_len; ///< ifd_data 指向的数据长度。 - void* ifd_data; ///< 命令参数。 + char ifd_name[IFNAMSIZ]; ///< Target interface name. + unsigned long ifd_cmd; ///< Driver-private command code (IF_FAKE_S_CMD_* for feth). + std::size_t ifd_len; ///< Length of the data ifd_data points to. + void* ifd_data; ///< Command argument. }; -// 大小必须正好 40 字节:它参与 _IOW('i', 123, struct ifdrv) 的 ioctl 编号计算, -// 算错的话得到的是一个不存在的 ioctl 号。实测 SIOCSDRVSPEC = 0x8028697b。 -static_assert(sizeof(IfDrv) == 40, "struct ifdrv 必须是 40 字节,否则 ioctl 编号会算错"); +// The size must be exactly 40 bytes: it participates in the ioctl-number +// computation of _IOW('i', 123, struct ifdrv); a wrong size yields a nonexistent +// ioctl number. Measured SIOCSDRVSPEC = 0x8028697b. +static_assert(sizeof(IfDrv) == 40, "struct ifdrv must be 40 bytes or the ioctl number is wrong"); static_assert(offsetof(IfDrv, ifd_cmd) == 16); static_assert(offsetof(IfDrv, ifd_len) == 24); static_assert(offsetof(IfDrv, ifd_data) == 32); -/// 设置驱动私有参数。等价于 SDK 的 SIOCSDRVSPEC,但用我们自己的 IfDrv 计算。 +/// Set driver-private parameters. Equivalent to the SDK's SIOCSDRVSPEC but +/// computed with our own IfDrv. inline constexpr unsigned long kSetDriverSpec = _IOW('i', 123, IfDrv); -/// 读取驱动私有参数。 +/// Read driver-private parameters. inline constexpr unsigned long kGetDriverSpec = _IOWR('i', 123, IfDrv); -static_assert(kSetDriverSpec == 0x8028'697BUL, "SIOCSDRVSPEC 编号与实测值不符"); -static_assert(kGetDriverSpec == 0xC028'697BUL, "SIOCGDRVSPEC 编号与实测值不符"); +static_assert(kSetDriverSpec == 0x8028'697BUL, "SIOCSDRVSPEC number != the measured value"); +static_assert(kGetDriverSpec == 0xC028'697BUL, "SIOCGDRVSPEC number != the measured value"); // ============================================================================= -// if_fake(feth)私有 ABI +// if_fake (feth) private ABI // -// 出处:xnu/bsd/net/if_fake_var.h +// Source: xnu/bsd/net/if_fake_var.h // ============================================================================= -/// feth 的驱动名(if_clone 名字,创建时填进 ifr_name 作为通配前缀)。 +/// The driver name of feth (the if_clone name; it goes into ifr_name as a +/// wildcard prefix when creating). inline constexpr const char* kFethCloneName = "feth"; -/// SIOCSDRVSPEC 的命令码。 +/// Command codes for SIOCSDRVSPEC. enum class FethSetCommand : unsigned long { kNone = 0, - kSetPeer = 1, ///< 配对 / 解绑 peer。 + kSetPeer = 1, ///< Pair / unbind the peer. kSetMedia = 2, kSetDequeueStall = 3, }; -/// SIOCGDRVSPEC 的命令码。 +/// Command codes for SIOCGDRVSPEC. enum class FethGetCommand : unsigned long { kNone = 0, kGetPeer = 1, }; -/// if_fake_media 的媒体类型列表上限。 +/// Upper bound of if_fake_media's media-type list. inline constexpr std::size_t kFethMediaListMax = 27; -/// SIOCSDRVSPEC / SIOCGDRVSPEC 对 feth 的参数块。 +/// Parameter struct for SIOCSDRVSPEC / SIOCGDRVSPEC on feth. /// -/// 布局必须与 xnu 的 struct if_fake_request 逐字节一致: -/// uint64_t iffr_reserved[4]; // 32 字节,**必须全零** -/// union { // 128 字节 +/// The layout must match xnu's struct if_fake_request byte for byte: +/// uint64_t iffr_reserved[4]; // 32 bytes, **all zeros required** +/// union { // 128 bytes /// char iffru_buf[128]; -/// struct if_fake_media iffru_media; // 也是 128 字节 +/// struct if_fake_media iffru_media; // also 128 bytes /// char iffru_peer_name[IFNAMSIZ]; /// uint32_t iffru_dequeue_stall; /// }; struct FethRequest { - /// 保留字段。内核会校验它**必须全为 0**,非零直接返回 EINVAL。 + /// Reserved field. The kernel requires it to be **all zeros**; a nonzero + /// value fails outright with EINVAL. std::uint64_t reserved[4]; union { @@ -110,97 +120,112 @@ struct FethRequest { std::uint32_t media_reserved[3]; std::int32_t list[kFethMediaListMax]; } media; - /// peer 接口名。写空串(首字节 '\0')表示解绑。 + + /// Peer interface name. Writing an empty string (first byte '\0') means + /// unbind. char peer_name[IFNAMSIZ]; std::uint32_t dequeue_stall; } u; }; -static_assert(sizeof(FethRequest) == 160, "struct if_fake_request 必须是 160 字节"); +static_assert(sizeof(FethRequest) == 160, "struct if_fake_request must be 160 bytes"); static_assert(offsetof(FethRequest, u) == 32); static_assert(sizeof(FethRequest::u) == 128); static_assert(alignof(FethRequest) == 8); // ============================================================================= -// BPF 私有常量与 ioctl +// BPF private constants and ioctls // -// 出处:xnu/bsd/net/bpf.h 的 PRIVATE 段(macOS 26 起挪到 bsd/net/bpf_private.h)。 -// SDK 均未提供,全部做运行时特性探测。 +// Source: the PRIVATE section of xnu/bsd/net/bpf.h (moved to +// bsd/net/bpf_private.h starting with macOS 26). The SDK provides none of +// them; everything is feature-probed at runtime. // ============================================================================= -/// 启用批量写:一次 write() 发送多帧。 +/// Enables batched writes: one write() sends multiple frames. /// -/// 内核实现出现在 macOS 14 / xnu-10063 起;macOS 13 及更早完全没有。 -/// 因此必须探测:ioctl 返回 ENOTTY / EINVAL 就回落到逐帧 write。 +/// The kernel implementation first appeared in macOS 14 / xnu-10063; macOS 13 +/// and earlier have none of it. So it must be probed: if the ioctl returns +/// ENOTTY / EINVAL, fall back to per-frame write. /// -/// 前置条件:BIOCSHDRCMPLT 必须已设为 1,且不得设置过 BIOCSETTC。 +/// Preconditions: BIOCSHDRCMPLT must already be set to 1, and BIOCSETTC must +/// not have been set. inline constexpr unsigned long kBpfSetBatchWrite = _IOW('B', 143, int); -static_assert(kBpfSetBatchWrite == 0x8004'428FUL, "BIOCSBATCHWRITE 编号与实测值不符"); +static_assert(kBpfSetBatchWrite == 0x8004'428FUL, "BIOCSBATCHWRITE number != the measured value"); -/// 关闭抓包时间戳,让 catchpacket 跳过 microtime() 调用。 +/// Disables capture timestamps so catchpacket skips its microtime() call. /// -/// 我们不需要时间戳(帧直接转发给 USB),关掉能省掉每帧一次时钟读取。 -/// 同样做特性探测,失败无害。 +/// We do not need timestamps (frames are forwarded straight to USB), so turning +/// them off saves one clock read per frame. Feature-probed the same way; +/// failure is harmless. inline constexpr unsigned long kBpfSetNoTimestamp = _IOW('B', 145, int); -static_assert(kBpfSetNoTimestamp == 0x8004'4291UL, "BIOCSNOTSTAMP 编号与实测值不符"); +static_assert(kBpfSetNoTimestamp == 0x8004'4291UL, "BIOCSNOTSTAMP number != the measured value"); -/// 显式设置抓包方向(BIOCSSEESENT 的更精确版本)。 +/// Sets the capture direction explicitly (a more precise BIOCSSEESENT). inline constexpr unsigned long kBpfSetDirection = _IOW('B', 138, unsigned int); -/// 抓包方向取值。 +/// Capture-direction values. inline constexpr unsigned int kBpfDirectionNone = 0; inline constexpr unsigned int kBpfDirectionIn = 0x1; inline constexpr unsigned int kBpfDirectionOut = 0x2; inline constexpr unsigned int kBpfDirectionInOut = kBpfDirectionIn | kBpfDirectionOut; -/// bpfwrite 允许的超长余量。 +/// The over-length leeway bpfwrite allows. /// -/// 内核检查是 `(len - hlen) > (ifp->if_mtu + BPF_WRITE_LEEWAY)` → EMSGSIZE。 -/// 在 BIOCSHDRCMPLT=1(我们的用法)下 hlen == 0,所以**整帧长度(含 14 字节 -/// 以太头)必须 <= 接口 MTU + 18**。MTU=1500 时上限 1518,刚好容得下标准 -/// 1514 帧和带 VLAN 标签的 1518 帧。 +/// The kernel check is `(len - hlen) > (ifp->if_mtu + BPF_WRITE_LEEWAY)` -> +/// EMSGSIZE. With BIOCSHDRCMPLT=1 (our usage) hlen == 0, so **the full frame +/// length (including the 14-byte Ethernet header) must be <= interface MTU + +/// 18**. At MTU=1500 the cap is 1518, which exactly fits standard 1514 frames +/// and 1518 frames carrying a VLAN tag. inline constexpr std::uint32_t kBpfWriteLeeway = 18; -/// BIOCSBLEN 的上下限。 +/// The bounds for BIOCSBLEN. /// -/// macOS 13(xnu-8792)起上限是 BPF_BUFSIZE_CAP,本机通过只读 sysctl -/// debug.bpf_bufsize_cap 暴露为 32 MiB。**超限不报错**,而是静默截到上限并 -/// 通过 _IOWR 把实际生效值写回参数 —— 所以必须使用写回值,见 bpf_link.cc。 +/// Since macOS 13 (xnu-8792) the upper bound is BPF_BUFSIZE_CAP, exposed on +/// this machine as 32 MiB via the read-only sysctl debug.bpf_bufsize_cap. +/// **Exceeding it raises no error**: the kernel silently clamps to the cap and +/// writes the actually-effective value back through _IOWR -- so you must use +/// the written-back value; see bpf_link.cc. inline constexpr std::uint32_t kBpfMinBufferBytes = 32; -/// BPF 记录头的最小长度。 +/// The minimum length of a BPF record header. /// -/// 关键陷阱:`sizeof(struct bpf_hdr)` 在 LP64 上是 **20**(18 字节内容被编译器 -/// 补齐到 20),而内核实际写入的 `bh_hdrlen` 对 DLT_EN10MB 是 **18** -/// (SIZEOF_BPF_HDR=18,bif_hdrlen = BPF_WORDALIGN(14+18) - 14 = 18)。 -/// **读取时必须用记录里的 bh_hdrlen,用 sizeof 会立刻错位。** +/// Critical trap: `sizeof(struct bpf_hdr)` on LP64 is **20** (18 bytes of +/// content padded by the compiler to 20), whereas the kernel's actual +/// `bh_hdrlen` for DLT_EN10MB is **18** +/// (SIZEOF_BPF_HDR=18, bif_hdrlen = BPF_WORDALIGN(14+18) - 14 = 18). +/// **When walking records you must use each record's own bh_hdrlen; using +/// sizeof desyncs the walk immediately.** inline constexpr std::uint32_t kBpfHeaderMinBytes = 18; static_assert(sizeof(struct bpf_hdr) == 20, - "LP64 下 sizeof(struct bpf_hdr) 应为 20(内容 18 字节 + 2 字节填充)"); + "LP64 sizeof(struct bpf_hdr) should be 20 (18 bytes content + 2 padding)"); static_assert(sizeof(struct BPF_TIMEVAL) == 8, - "LP64 下 BPF 时间戳是 timeval32(8 字节),不是 64 位 timeval"); + "LP64 BPF timestamps are timeval32 (8 bytes), not 64-bit timeval"); // ============================================================================= -// feth 创建期会被快照的 sysctl +// sysctls snapshotted at feth creation time // -// 这些开关在 feth_clone_create() 那一刻从 sysctl 读进接口的私有标志位, -// **创建之后再改 sysctl 无效**。因此必须在创建 feth 之前校验它们。 +// These switches are read from sysctl into the interface's private flags at +// the instant of feth_clone_create(); **changing the sysctl after creation has +// no effect**. So they must be validated before creating the feth. // ============================================================================= -/// 一个必须为特定值的 feth sysctl。 +/// A feth sysctl whose value must be a specific number. /// -/// `why` 存的是文案标识而不是现成的字符串:这张表是 constexpr 的,而「为什么」 -/// 要按用户当前语言渲染,只能推迟到出错那一刻再查表。 +/// `why` stores a message identifier rather than a ready-made string: this +/// table is constexpr, and the "why" has to be rendered in the user's current +/// language, which can only happen at the moment of failure by consulting the +/// message table then. struct RequiredFethSysctl { const char* name; std::int32_t required_value; Msg why; }; -/// 创建 feth 前必须校验的 sysctl 清单。 +/// The sysctls that must be verified before creating a feth. /// -/// 这些值如果不对,我们从 BPF 读到的帧就不是「干净的以太帧」: +/// If any of these values is wrong, the frames we read from BPF are not +/// "clean Ethernet frames": inline constexpr RequiredFethSysctl kRequiredFethSysctls[] = { {"net.link.fake.hwcsum", 0, Msg::kNetSysctlWhyHwcsum}, {"net.link.fake.fcs", 0, Msg::kNetSysctlWhyFcs}, diff --git a/include/tetherkit/net/feth_device.h b/include/tetherkit/net/feth_device.h index 08720cd..c7c586a 100644 --- a/include/tetherkit/net/feth_device.h +++ b/include/tetherkit/net/feth_device.h @@ -1,23 +1,27 @@ -// feth(if_fake)虚拟网卡对的生命周期管理。 +// Lifecycle management for a feth (if_fake) virtual interface pair. // -// 拓扑与数据流向(已对照 xnu 源码 feth_output_common() 确认): +// Topology and data flow (verified against the xnu source feth_output_common()): // -// 主机 IP 栈 -// │ 配 IP / 路由 / DHCP -// ▼ -// ┌────────┐ peer 配对 ┌────────┐ BPF -// │ feth0 │ ◄───────────► │ feth1 │ ◄──────────► TetherKit -// │(系统侧)│ │(驱动侧)│ -// └────────┘ └────────┘ +// Host IP stack +// | IP / routes / DHCP +// v +// +--------+ peer pairing +--------+ BPF +// | feth0 | <-----------> | feth1 | <----------> TetherKit +// |(system)| |(driver)| +// +--------+ +--------+ // -// * 主机从 feth0 发出的帧 → 在 feth1 上是 **input** 方向,被 BPF 抓到; -// * 我们向 feth1 的 BPF **write** 一帧 → 走 feth1 的 output 路径 → -// 进入 feth0 的 input → 被主机 IP 栈收到。**不会** loopback 回 feth1。 +// * Frames the host sends from feth0 arrive on feth1 as the **input** +// direction, where BPF captures them. +// * A frame we **write** to feth1's BPF takes feth1's output path and enters +// feth0's input, where the host IP stack receives it. It does **not** +// loop back to feth1. // -// 所以 BPF 只需要挂在 feth1 上,一个描述符同时完成收和发。 +// So BPF only needs to attach to feth1; one descriptor both sends and +// receives. // -// 权限:本文件所有操作都需要 root(SIOCIFCREATE / SIOCSDRVSPEC / SIOCSIFLLADDR / -// SIOCSIFFLAGS / SIOCSIFMTU 在内核里都有 proc_suser 检查)。 +// Permissions: every operation in this file requires root (SIOCIFCREATE / +// SIOCSDRVSPEC / SIOCSIFLLADDR / SIOCSIFFLAGS / SIOCSIFMTU all carry a +// proc_suser check in the kernel). #pragma once #include @@ -29,49 +33,61 @@ namespace tetherkit::net { -/// 6 字节 MAC 地址。 +/// A 6-byte MAC address. using MacAddress = std::array; -/// feth 接口名的最大长度(IFNAMSIZ = 16,含终止符)。 +/// Max length of a feth interface name (IFNAMSIZ = 16, terminator included). inline constexpr std::size_t kInterfaceNameCapacity = 16; -/// feth 支持的 MTU 上限,由 sysctl net.link.fake.max_mtu 决定(本机 2048)。 +/// The MTU cap feth supports, decided by sysctl net.link.fake.max_mtu +/// (2048 on this machine). /// -/// 注意该 sysctl 也是**创建期快照**:feth_max_mtu 在 clone_create 时算定, -/// 之后改 sysctl 对已存在的接口无效。 +/// Note this sysctl is also a **creation-time snapshot**: feth_max_mtu is +/// computed at clone_create time, and changing the sysctl later has no effect +/// on existing interfaces. [[nodiscard]] Result QueryFethMaxMtu(); -/// 校验 feth 创建期会快照的那批 sysctl 是否都处于我们要求的值。 +/// Verifies that the sysctls snapshotted at feth creation time all hold the +/// values we require. /// -/// **必须在创建 feth 之前调用。** 这些开关(hwcsum / fcs / tso_support / lro / -/// trailer_length / separate_frame_header)一旦在创建时被快照成非 0,我们从 -/// BPF 读到的就不是干净的以太帧,而创建后再改 sysctl 已经来不及了。 +/// **Must be called before creating the feth.** If any of these switches +/// (hwcsum / fcs / tso_support / lro / trailer_length / separate_frame_header) +/// is snapshotted nonzero at creation, the frames we read from BPF are not +/// clean Ethernet frames -- and changing the sysctl after creation is too late. [[nodiscard]] Status VerifyFethSysctls(); -/// 接口创建 / 销毁的登记回调。 +/// Registration callback for interface creation / destruction. /// -/// ★ 为什么需要它 ★ -/// 进程被 SIGKILL 时 C++ 的析构不会跑,feth 会留在内核里;而 SIGKILL 是任何 -/// 信号处理器都拦不住的。唯一可靠的兜底是**把创建过的接口名落盘,下次启动时 -/// 清理**。RAII 管不了被强杀的进程,这个钩子就是为此存在的。 +/// Why it exists: +/// When the process is killed with SIGKILL, C++ destructors never run and +/// the feth stays in the kernel; SIGKILL defeats any signal handler. The +/// only reliable backstop is **persisting the names of created interfaces +/// and cleaning them up on the next start**. RAII cannot save a forcibly +/// killed process; this hook exists for exactly that. /// -/// 约束:会在创建 / 销毁的同一线程上同步调用,实现里只该做「往文件里加一行 / -/// 删一行」这种短操作,不要打日志(销毁路径可能在析构里,noexcept)。 +/// Constraint: called synchronously on the same thread as the create / destroy +/// operation. The implementation should only do short work like appending or +/// deleting a line in a file, and must not log (the destroy path can run +/// inside a destructor, noexcept). /// -/// @param name 接口名。 -/// @param created true = 刚创建,false = 刚销毁。 +/// @param name Interface name. +/// @param created true = just created, false = just destroyed. using InterfaceRegistry = void (*)(std::string_view name, bool created) noexcept; -/// 安装 / 卸载登记回调。传 nullptr 卸载。进程级,线程安全。 +/// Installs / uninstalls the registration callback. Pass nullptr to uninstall. +/// Process-wide, thread-safe. void SetInterfaceRegistry(InterfaceRegistry registry) noexcept; -/// 销毁一张按名字指定的 feth 接口。用于清理进程被强杀后残留的孤儿。 +/// Destroys a feth interface given by name. Used to clean up orphans left +/// behind after a process was forcibly killed. /// -/// 与 FethInterface 的析构不同,这里不需要先持有对象 —— 孤儿正是「没有对象在 -/// 管」的那些。名字必须形如 `feth<数字>`,调用方负责校验。 +/// Unlike FethInterface's destructor, this does not need to hold the object +/// first -- an orphan is precisely an interface no object is managing. The +/// name must look like `feth`; validating that is the caller's job. [[nodiscard]] Status DestroyInterfaceByName(std::string_view name); -/// 一张 feth 接口。RAII:析构时销毁内核里的接口。 +/// One feth interface. RAII: the destructor destroys the interface in the +/// kernel. class FethInterface { public: FethInterface() = default; @@ -81,99 +97,114 @@ class FethInterface { FethInterface(FethInterface&& other) noexcept; FethInterface& operator=(FethInterface&& other) noexcept; - /// 销毁接口。内核的 feth_clone_destroy 会自动先解绑 peer。 + /// Destroys the interface. The kernel's feth_clone_destroy automatically + /// unbinds the peer first. ~FethInterface(); - /// 创建一张新的 feth 接口。 + /// Creates a new feth interface. /// - /// @param requested_name 传空串表示让内核自动选最小空闲编号(推荐); - /// 传 "feth7" 表示指定编号,已存在则失败(EEXIST)。 + /// @param requested_name an empty string lets the kernel pick the smallest + /// free unit number automatically (recommended); + /// "feth7" pins the number and fails with EEXIST if + /// it already exists. /// - /// 内核在通配模式下会把完整名字写回,可用 Name() 取得。 + /// In wildcard mode the kernel writes the full name back; obtain it via + /// Name(). [[nodiscard]] static Result Create(std::string_view requested_name = {}); [[nodiscard]] bool Valid() const noexcept { return !name_.empty(); } [[nodiscard]] std::string_view Name() const noexcept { return name_; } - /// 与另一张 feth 接口配对。 + /// Pairs with another feth interface. /// - /// 内核对此有五项硬性校验,任一不满足返回 EINVAL: - /// 1. ifd_len >= sizeof(if_fake_request)(160); - /// 2. if_fake_request 的 reserved 字段必须全零; - /// 3. peer 必须也是 feth(ifnet_name() == "feth" 且 type == IFT_ETHER); - /// 4. 双方都不能已有 peer; - /// 5. 调用者必须是 root。 + /// The kernel enforces five hard checks; any failure returns EINVAL: + /// 1. ifd_len >= sizeof(if_fake_request) (160); + /// 2. if_fake_request's reserved field must be all zeros; + /// 3. the peer must also be a feth (ifnet_name() == "feth" and + /// type == IFT_ETHER); + /// 4. neither side may already have a peer; + /// 5. the caller must be root. [[nodiscard]] Status PeerWith(const FethInterface& peer); - /// 解绑 peer(写入空的 peer 名)。 + /// Unbinds the peer (writes an empty peer name). [[nodiscard]] Status Unpeer(); - /// 查询当前 peer 名;未配对时返回空串。 + /// Queries the current peer name; empty string when unpaired. [[nodiscard]] Result QueryPeer() const; - /// 设置 MTU。上限由 QueryFethMaxMtu() 给出,超限返回 EINVAL。 + /// Sets the MTU. The cap comes from QueryFethMaxMtu(); exceeding it returns + /// EINVAL. [[nodiscard]] Status SetMtu(std::uint32_t mtu); [[nodiscard]] Result QueryMtu() const; - /// 设置 MAC 地址。**必须在置 IFF_UP 之前调用。** + /// Sets the MAC address. **Must be called before setting IFF_UP.** /// - /// 内核默认分配的 MAC 是 'f','e','t','h', unit>>8, unit&0xff - /// (所以 feth0 是 66:65:74:68:00:00)。 + /// The kernel's default MAC is 'f','e','t','h', unit>>8, unit&0xff + /// (so feth0 is 66:65:74:68:00:00). /// - /// 对 RNDIS 场景应把**系统侧**那张(feth0)的 MAC 设为设备通过 - /// OID_802_3_PERMANENT_ADDRESS 汇报的地址 —— RNDIS 语义下设备就是这块网卡, - /// 对端的 ARP 表与 DHCP 租约都按这个 MAC 建立。 + /// In the RNDIS scenario the **system-side** interface (feth0) should take + /// the address the device reports via OID_802_3_PERMANENT_ADDRESS -- in + /// RNDIS semantics the device is this interface, and the peer's ARP table + /// and DHCP leases are all keyed on this MAC. /// - /// **驱动侧那张(feth1)必须保留内核分配的、与 feth0 不同的 MAC**, - /// 否则两侧的 IPv6 链路本地地址相同,会触发 DAD 地址冲突。 + /// **The driver-side interface (feth1) must keep the kernel-assigned MAC, + /// different from feth0's**; otherwise both sides get the same IPv6 + /// link-local address and DAD reports an address conflict. [[nodiscard]] Status SetMacAddress(const MacAddress& mac); [[nodiscard]] Result QueryMacAddress() const; - /// 置 IFF_UP / 清 IFF_UP。内核在置 UP 时会自动补上 IFF_RUNNING。 + /// Sets / clears IFF_UP. The kernel automatically adds IFF_RUNNING when + /// setting UP. [[nodiscard]] Status SetUp(bool up); [[nodiscard]] Result IsUp() const; - /// 放弃对接口的所有权,析构时不再销毁它(用于排障时保留现场)。 + /// Gives up ownership of the interface; the destructor no longer destroys + /// it (used to keep the live state around while debugging). void Release() noexcept { name_.clear(); } private: explicit FethInterface(std::string name) : name_(std::move(name)) {} - /// 销毁内核里的接口并清空 name_。失败只记日志 —— 析构路径不能抛也不能返回错误。 + /// Destroys the interface in the kernel and clears name_. Failure is only + /// logged -- a destructor path can neither throw nor return errors. void Destroy() noexcept; std::string name_; }; -/// 配置完成、可以投入使用的 feth 接口对。 +/// A pair of feth interfaces, fully configured and ready for use. class FethPair { public: - /// 创建并配置一对 feth。 + /// Creates and configures a pair of feth interfaces. /// - /// 操作顺序是刻意安排的(顺序错了会失败或产生错误状态): - /// 1. 校验创建期 sysctl —— 必须在创建之前; - /// 2. 创建两张接口; - /// 3. 配对 —— 在 UP 之前配对,这样链路一开始就是 up 的 - /// (SIOCGIFMEDIA 的 IFM_ACTIVE 立刻为真); - /// 4. 设 MTU; - /// 5. 设系统侧 MAC —— 必须在 UP 之前; - /// 6. 两侧都置 UP —— bpfwrite 硬性要求 feth1 是 UP(否则 ENETDOWN), - /// feth0 也必须 UP 才能让主机 IP 栈处理收到的帧。 + /// The operation order is deliberate (a wrong order fails or leaves bad + /// state): + /// 1. Verify the creation-time sysctls -- before creating anything; + /// 2. Create both interfaces; + /// 3. Pair -- pair before UP so the link is up from the start + /// (SIOCGIFMEDIA's IFM_ACTIVE is immediately true); + /// 4. Set the MTU; + /// 5. Set the system-side MAC -- must happen before UP; + /// 6. Set both sides UP -- bpfwrite hard-requires feth1 to be UP + /// (otherwise ENETDOWN), and feth0 must be UP for the host IP stack to + /// process received frames. /// - /// @param mtu 两侧的 MTU。 - /// @param system_mac 系统侧(host_side)要设置的 MAC;传 std::nullopt - /// 表示保留内核分配的地址。 + /// @param mtu The MTU for both sides. + /// @param system_mac The MAC to set on the system side; pass std::nullopt + /// to keep the kernel-assigned address. [[nodiscard]] static Result Create(std::uint32_t mtu, const MacAddress* system_mac = nullptr); - /// 系统侧接口:主机在这张网卡上配 IP、跑 DHCP、加路由。 + /// The system-side interface: the host assigns IPs, runs DHCP, and adds + /// routes on this one. [[nodiscard]] const FethInterface& SystemSide() const noexcept { return system_side_; } - /// 驱动侧接口:TetherKit 把 BPF 挂在这张上收发原始帧。 + /// The driver-side interface: TetherKit attaches BPF to it to receive and + /// send raw frames. [[nodiscard]] const FethInterface& DriverSide() const noexcept { return driver_side_; } [[nodiscard]] FethInterface& SystemSide() noexcept { return system_side_; } @@ -188,10 +219,11 @@ class FethPair { FethInterface driver_side_; }; -/// 把 MAC 渲染成 "aa:bb:cc:dd:ee:ff"。 +/// Formats a MAC as "aa:bb:cc:dd:ee:ff". [[nodiscard]] std::array FormatMac(const MacAddress& mac) noexcept; -/// 当前进程是否以 root 运行。用于在启动时给出明确的报错而非一串 EPERM。 +/// Whether the current process runs as root. Used to produce a clear error at +/// startup instead of a stream of EPERM. [[nodiscard]] bool IsRunningAsRoot() noexcept; } // namespace tetherkit::net diff --git a/include/tetherkit/net/link_backend.h b/include/tetherkit/net/link_backend.h index 872a7fe..59ae578 100644 --- a/include/tetherkit/net/link_backend.h +++ b/include/tetherkit/net/link_backend.h @@ -1,13 +1,16 @@ -// 链路层后端抽象。 +// Link-layer backend abstraction. // -// 为什么需要抽象:BPF 需要 root 且需要真实的 feth 接口,开发机上跑不了。 -// 把「收发原始以太帧」抽象成一个窄接口后,端到端测试与吞吐基准就能用内存 -// loopback 后端在任何环境下跑。 +// Why the abstraction exists: BPF needs root and a real feth interface, which +// a developer machine cannot provide. Once "sending and receiving raw Ethernet +// frames" sits behind a narrow interface, end-to-end tests and throughput +// benchmarks can run anywhere on the in-memory loopback backend. // -// 性能取舍:接口用**虚函数**,但虚调用**不在每帧的粒度上** —— -// ReadFrames / WriteFrames 都是批量接口,一次虚调用处理几十上百帧, -// 分摊到每帧的开销远小于 1 ns,完全可以忽略。 -// (如果做成每帧一次虚调用就不可接受了,那是刻意避开的设计。) +// Performance tradeoff: the interface uses **virtual functions**, but the +// virtual dispatch is **not per frame** -- ReadFrames / WriteFrames are batch +// interfaces; one virtual call handles dozens to hundreds of frames, and the +// per-frame amortized cost is far below 1 ns, entirely negligible. +// (A per-frame virtual call would have been unacceptable; that is the design +// deliberately avoided here.) #pragma once #include @@ -19,26 +22,29 @@ namespace tetherkit::net { -/// 一批待发送的帧。 +/// A batch of frames to send. using FrameBatch = std::span; -/// 一次批量读取的结果。 +/// The result of one batched read. struct ReadBatch { - /// 本批解出的帧,视图指向后端内部缓冲,在下一次 ReadFrames 前有效。 + /// Frames decoded in this batch; the views point into the backend's internal + /// buffer and stay valid until the next ReadFrames call. std::span frames; - /// 内核侧累计丢包数(BPF 的 bs_drop)。用于背压告警。 + /// Cumulative kernel-side drop count (BPF's bs_drop). Used for backpressure + /// alerts. std::uint64_t kernel_drops = 0; }; -/// 一次批量写入的结果。 +/// The result of one batched write. struct WriteResult { std::uint32_t frames_written = 0; std::uint64_t bytes_written = 0; - /// 因单帧超长等原因被跳过的帧数。 + /// Frames skipped, for example because a single frame exceeded the length + /// cap. std::uint32_t frames_skipped = 0; }; -/// 收发原始以太帧的后端。 +/// A backend that sends and receives raw Ethernet frames. class LinkBackend { public: LinkBackend() = default; @@ -48,24 +54,27 @@ class LinkBackend { LinkBackend& operator=(LinkBackend&&) = delete; virtual ~LinkBackend() = default; - /// 阻塞读取一批帧。 + /// Blocks until a batch of frames has been read. /// - /// 返回空批次是合法的(例如被信号打断),调用方应继续循环。 - /// 只有真正的错误才返回 Error。 + /// An empty batch is a legitimate return (for example when interrupted by a + /// signal); the caller should keep looping. Only a genuine error returns an + /// Error. [[nodiscard]] virtual Result ReadFrames() = 0; - /// 写出一批帧。尽最大努力写完;超长帧被跳过并计数。 + /// Writes a batch of frames. Best effort; over-length frames are skipped and + /// counted. [[nodiscard]] virtual Result WriteFrames(FrameBatch frames) = 0; - /// 单帧长度上限(含 14 字节以太头)。 + /// Max single-frame length (14-byte Ethernet header included). [[nodiscard]] virtual std::uint32_t MaxFrameBytes() const noexcept = 0; - /// 是否支持一次系统调用写多帧。用于日志与基准报告。 + /// Whether one syscall can write multiple frames. Used for logging and + /// benchmark reports. [[nodiscard]] virtual bool SupportsBatchWrite() const noexcept = 0; - /// 唤醒阻塞在 ReadFrames 里的线程,用于优雅停机。 + /// Wakes a thread blocked inside ReadFrames, for graceful shutdown. /// - /// 必须可以从**其它线程**安全调用。 + /// Must be safe to call from **another thread**. virtual void Interrupt() noexcept = 0; }; diff --git a/include/tetherkit/net/loopback_link.h b/include/tetherkit/net/loopback_link.h index 9bdb6cd..0dc0aff 100644 --- a/include/tetherkit/net/loopback_link.h +++ b/include/tetherkit/net/loopback_link.h @@ -1,13 +1,19 @@ -// 内存 loopback 链路后端 —— 用于离线测试与吞吐基准。 +// In-memory loopback link backend -- for offline tests and throughput +// benchmarks. // -// 为什么必须有它:BpfLink 需要 root 权限 + 真实的 feth 接口,而开发机上既没有 -// root 也没有 USB 设备。把链路层抽象成 LinkBackend 之后,端到端逻辑(桥接层的 -// 线程模型、批处理、背压、统计)就能在任何环境下被完整测试和基准。 +// Why it must exist: BpfLink needs root privileges plus a real feth interface, +// and a developer machine has neither root nor a USB device. Once the link +// layer sits behind the LinkBackend abstraction, the end-to-end logic (the +// bridge's threading model, batching, backpressure, statistics) can be tested +// and benchmarked in full on any machine. // -// 语义: -// * WriteFrames 写入的帧进入 `sent` 队列,供测试断言「驱动往主机侧发了什么」; -// * ReadFrames 从 `inbound` 队列取帧,供测试注入「主机侧发来了什么」; -// * 两个队列都是有界的,满了就丢弃并计数 —— 与真实 BPF 的行为一致。 +// Semantics: +// * Frames written by WriteFrames land in the `sent` queue, so tests can +// assert "what the driver sent to the host side"; +// * ReadFrames takes frames from the `inbound` queue, so tests can inject +// "what the host side sent"; +// * Both queues are bounded; a full queue drops the frame and counts it -- +// the same behavior as real BPF. #pragma once #include @@ -20,29 +26,33 @@ namespace tetherkit::net { -/// loopback 后端的配置。 +/// Configuration of the loopback backend. struct LoopbackConfig { std::uint32_t max_frame_bytes = 1518; - std::size_t inbound_capacity = 4096; ///< 待被 ReadFrames 取走的帧数上限。 - std::size_t sent_capacity = 4096; ///< 已被 WriteFrames 写出的帧数上限。 + std::size_t inbound_capacity = 4096; ///< Cap on frames waiting to be taken by ReadFrames. + std::size_t sent_capacity = 4096; ///< Cap on frames already written by WriteFrames. std::size_t max_frames_per_batch = 256; - /// 是否宣称支持批量写。测试里两种路径都要覆盖。 + /// Whether to advertise batch write support. Tests cover both paths. bool report_batch_write = true; }; -/// 纯内存的链路后端。 +/// A purely in-memory link backend. /// -/// 线程安全:ReadFrames 只允许一个线程调用,WriteFrames 只允许一个线程调用 -/// (与 LinkBackend 的契约一致);而 PushInbound / DrainSent / Interrupt 可以 -/// 从任意线程调用,内部用互斥锁保护。 +/// Thread safety: ReadFrames may be called from one thread only, WriteFrames +/// may be called from one thread only (matching the LinkBackend contract); +/// PushInbound / DrainSent / Interrupt may be called from any thread and are +/// protected internally by a mutex. /// -/// 这里刻意用互斥锁而不是无锁队列:本类只服务测试与基准的**注入侧**, -/// 不在被测的数据路径上;无锁化只会增加实现复杂度却测不出更多东西。 +/// A mutex is used deliberately instead of a lock-free queue: this class only +/// serves the **injection side** of tests and benchmarks and is not on the +/// data path under test; making it lock-free would add implementation +/// complexity without testing anything more. class LoopbackLink final : public LinkBackend { public: explicit LoopbackLink(const LoopbackConfig& config = {}); - // 拷贝与移动已在基类 LinkBackend 中删除,这里显式重申以满足静态检查。 + // Copy and move are already deleted in the base class LinkBackend; they are + // restated here to satisfy the static checks. LoopbackLink(const LoopbackLink&) = delete; LoopbackLink& operator=(const LoopbackLink&) = delete; LoopbackLink(LoopbackLink&&) = delete; @@ -63,16 +73,17 @@ class LoopbackLink final : public LinkBackend { void Interrupt() noexcept override; // --------------------------------------------------------------------------- - // 测试注入与观测接口 + // Test injection and observation interface // --------------------------------------------------------------------------- - /// 注入一帧「来自主机侧」的数据,供 ReadFrames 取出。队列满返回 false。 + /// Injects one frame "from the host side" for ReadFrames to take. Returns + /// false when the queue is full. [[nodiscard]] bool PushInbound(std::span frame); - /// 取出并清空「驱动写给主机侧」的全部帧。 + /// Takes and clears every frame "the driver wrote to the host side". [[nodiscard]] std::vector> DrainSent(); - /// 已写出的帧数与字节数(累计,不受 DrainSent 影响)。 + /// Frames and bytes written (cumulative, unaffected by DrainSent). [[nodiscard]] std::uint64_t TotalSentFrames() const noexcept { return total_sent_frames_.load(std::memory_order_relaxed); } @@ -81,17 +92,18 @@ class LoopbackLink final : public LinkBackend { return total_sent_bytes_.load(std::memory_order_relaxed); } - /// 因队列满而丢弃的注入帧数。 + /// Number of injected frames dropped because the queue was full. [[nodiscard]] std::uint64_t InboundDrops() const noexcept { return inbound_drops_.load(std::memory_order_relaxed); } - /// 是否已被 Interrupt。 + /// Whether Interrupt has been called. [[nodiscard]] bool Interrupted() const noexcept { return interrupted_.load(std::memory_order_acquire); } - /// 让 WriteFrames 从第 N 次调用起返回错误,用于测试错误传播路径。 + /// Makes WriteFrames return an error from the Nth call on, to exercise the + /// error propagation path. void FailWritesAfter(std::uint32_t successful_calls) noexcept { fail_writes_after_.store(successful_calls, std::memory_order_relaxed); } @@ -100,12 +112,13 @@ class LoopbackLink final : public LinkBackend { LoopbackConfig config_; mutable std::mutex mutex_; - /// 待读取的帧(FIFO)。 + /// Frames waiting to be read (FIFO). std::vector> inbound_; - /// 已写出的帧。 + /// Frames already written. std::vector> sent_; - /// ReadFrames 返回的视图必须在下次调用前保持有效,因此本批数据留在这里。 + /// The views returned by ReadFrames must stay valid until the next call, so + /// this batch's data is kept here. std::vector> read_storage_; std::vector read_views_; diff --git a/include/tetherkit/rndis/control_channel.h b/include/tetherkit/rndis/control_channel.h index f1d1e94..5345820 100644 --- a/include/tetherkit/rndis/control_channel.h +++ b/include/tetherkit/rndis/control_channel.h @@ -1,23 +1,30 @@ -// RNDIS 控制通道抽象。 +// RNDIS control channel abstraction. // -// 为什么这个接口放在 rndis 层而不是 usb 层:它描述的是**RNDIS 协议**的控制通道 -// 语义(发一条消息、取一条消息、等一个「有响应了」的通知),而不是 USB 的语义。 -// 状态机(tk_rndis)依赖它,libusb 实现(tk_usb)提供它 —— 这样依赖方向就是 -// 正确的 rndis ← usb,而不会出现 tk_rndis 反向依赖 tk_usb。 +// Why this interface lives in the rndis layer rather than the usb layer: it describes the +// **RNDIS protocol** control-channel semantics (send a message, fetch a message, wait +// for a "response available" notification), not USB semantics. The state machine +// (tk_rndis) depends on it and the libusb implementation (tk_usb) provides it -- so the +// dependency direction is the correct rndis <- usb, with no reverse dependency from +// tk_rndis on tk_usb. // -// RNDIS 的控制消息不走 bulk 端点,而是走 USB 控制端点(EP0)的两个类请求: +// RNDIS control messages do not go through bulk endpoints, but through two class +// requests on the USB control endpoint (EP0): // SEND_ENCAPSULATED_COMMAND (bmRequestType=0x21, bRequest=0x00) // GET_ENCAPSULATED_RESPONSE (bmRequestType=0xA1, bRequest=0x01) -// 外加通信类接口上的中断 IN 端点,用来通知「有响应可取了」。 +// plus the interrupt IN endpoint on the communication-class interface, which notifies +// that "a response is available". // -// 为什么要抽象成接口:状态机是整个 RNDIS 实现里逻辑最复杂、最需要测试的部分, -// 而开发机上没有任何 USB 设备。把控制通道抽象掉之后,状态机可以完全在内存里 -// 被驱动,包括那些真机上极难复现的路径(设备主动发 KEEPALIVE、 -// INDICATE_STATUS 插队、RESET 后要求重放、响应乱序、超时重试)。 +// Why abstract this as an interface: the state machine is the most logically complex and +// most test-demanding part of the RNDIS implementation, while the development machine has +// no USB devices at all. With the control channel abstracted away, the state machine can +// be driven entirely in memory, including paths that are extremely hard to reproduce on +// a real device (device-initiated KEEPALIVE, INDICATE_STATUS jumping the queue, +// re-sending required after RESET, out-of-order responses, timeout retries). // -// 性能上这里用虚函数完全没问题:控制通道每 5 秒才有一次保活往返, -// 而且 Apple Silicon 上 libusb 的控制传输本身就是**毫秒**级 -// (libusb issue #1288:M2 上 control transfer 比 x64 慢约 10 倍)。 +// Virtual dispatch here is no performance concern: the control channel does one +// keep-alive round trip every 5 seconds, and on Apple Silicon libusb control transfers +// themselves are **millisecond**-scale +// (libusb issue #1288: control transfers on M2 are about 10x slower than on x64). #pragma once #include @@ -29,27 +36,31 @@ namespace tetherkit::rndis { -/// 「只探一下、不要真等」时应传的超时值。 +/// Timeout value to pass when "just probing, do not really wait". /// -/// ⚠️ **不能传 0。** libusb 在 darwin 上把 timeout 同时作为 noDataTimeout 与 -/// completionTimeout 传给 IOKit,而 **0 表示无限等待** —— 传 0 会让调用线程 -/// 永久阻塞在 `libusb_wait_for_event` 上,整个控制循环卡死、连停机信号都响应不了。 -/// (详见 AGENTS.md 第 7 节第 12、13 条。) -/// libusb 的同步 API 没有真正的非阻塞模式,能做到的最短等待就是 1 ms。 +/// **Must not be 0.** On darwin, libusb passes timeout to IOKit as both noDataTimeout +/// and completionTimeout, and **0 means wait forever** -- passing 0 blocks the calling +/// thread in `libusb_wait_for_event` forever, the entire control loop hangs, and even +/// shutdown signals cannot be handled. +/// (See AGENTS.md section 7, pitfalls 12 and 13.) +/// libusb's synchronous API has no true non-blocking mode; the shortest wait possible is +/// 1 ms. inline constexpr std::uint32_t kProbeOnlyTimeoutMillis = 1; -/// 等待通知的结果。 +/// Result of waiting for a notification. enum class NotificationResult : std::uint8_t { - kResponseAvailable, ///< 设备明确通知有响应可取。 - kTimeout, ///< 等待超时。**不一定是错误** —— 见下方说明。 - kNotSupported, ///< 设备没有中断端点,调用方应直接轮询。 + kResponseAvailable, ///< The device explicitly notified that a response is available. + kTimeout, ///< Wait timed out. **Not necessarily an error** -- see below. + kNotSupported, ///< The device has no interrupt endpoint; the caller should poll + ///< directly. }; -/// RNDIS 控制通道。 +/// RNDIS control channel. /// -/// 实现必须保证:所有方法都从**同一个线程**调用(状态机线程)。这不是为了 -/// 简化实现,而是 libusb 的硬性约束 —— 同步 API 从事件线程调用会返回 -/// LIBUSB_ERROR_BUSY,所以控制通道必须独占一个非事件线程。 +/// Implementations must guarantee: all methods are called from **the same thread** (the +/// state-machine thread). This is not to simplify the implementation but a hard libusb +/// constraint -- calling the synchronous API from the event thread returns +/// LIBUSB_ERROR_BUSY, so the control channel must own a non-event thread exclusively. class ControlChannel { public: ControlChannel() = default; @@ -59,37 +70,41 @@ class ControlChannel { ControlChannel& operator=(ControlChannel&&) = delete; virtual ~ControlChannel() = default; - /// 发送一条 RNDIS 控制消息(SEND_ENCAPSULATED_COMMAND)。 + /// Send an RNDIS control message (SEND_ENCAPSULATED_COMMAND). [[nodiscard]] virtual Status SendMessage(std::span message) = 0; - /// 取回一条 RNDIS 控制消息(GET_ENCAPSULATED_RESPONSE)。 + /// Retrieve an RNDIS control message (GET_ENCAPSULATED_RESPONSE). /// - /// 返回的视图指向实现内部的缓冲,在下一次调用本方法之前有效。 + /// The returned view points into the implementation's internal buffer and remains valid + /// until the next call to this method. /// - /// **返回空视图不是错误**:规范规定设备在尚无有效响应时应返回**1 字节 0x00** - /// 而不是 STALL 控制端点。因此调用方收到长度 < 8 字节的结果必须当作 - /// 「还没准备好,稍后重试」,而不是当作失败。 + /// **An empty view is not an error**: the specification says that when no valid + /// response is ready yet the device should return **1 byte 0x00** rather than STALL the + /// control endpoint. Therefore a result shorter than 8 bytes must be treated by the + /// caller as "not ready yet, retry later", not as a failure. [[nodiscard]] virtual Result> ReceiveMessage() = 0; - /// 等待设备的 RESPONSE_AVAILABLE 通知。 + /// Wait for the device's RESPONSE_AVAILABLE notification. /// - /// 中断端点的处理必须同时兼容两类设备行为: - /// * 规范做法:host 等中断 IN 上的 8 字节通知 - /// (两个 LE32:0x00000001 = RESPONSE_AVAILABLE,0); - /// * Linux 做法:完全忽略中断端点,直接对 GET_ENCAPSULATED_RESPONSE - /// 轮询最多 10 次、每次间隔 40 ms。 - /// 而且反过来还存在**某些设备必须先被中断端点读过一次才会在控制端点上作答**。 - /// 所以正确策略是:先等通知,超时后仍然去轮询一次。kTimeout 因此不是错误。 + /// Interrupt endpoint handling must be compatible with two device behaviors: + /// * The specification behavior: host waits for an 8-byte notification on interrupt + /// IN (two LE32s: 0x00000001 = RESPONSE_AVAILABLE, 0); + /// * The Linux behavior: completely ignore the interrupt endpoint and poll + /// GET_ENCAPSULATED_RESPONSE directly up to 10 times with 40 ms intervals. + /// And conversely, there are **devices that must have the interrupt endpoint read once + /// before they will answer on the control endpoint**. So the correct strategy is: wait + /// for the notification, but still poll once after timeout. kTimeout is therefore not + /// an error. /// - /// @param timeout_millis 等待上限。**传 0 表示无限等待**(与 libusb 的语义 - /// 一致)—— 想「只探一下」请传 kProbeOnlyTimeoutMillis,不要传 0。 - /// 实现应对 0 做防御性钳位,见 UsbControlChannel。 + /// @param timeout_millis Wait limit. **0 means wait forever** (consistent with libusb + /// semantics) -- to "just probe" pass kProbeOnlyTimeoutMillis, not 0. + /// Implementations should clamp 0 defensively; see UsbControlChannel. [[nodiscard]] virtual NotificationResult WaitForNotification(std::uint32_t timeout_millis) = 0; - /// 控制传输的超时(毫秒)。 + /// Control transfer timeout (milliseconds). [[nodiscard]] virtual std::uint32_t TimeoutMillis() const noexcept = 0; - /// 供日志使用的可读标识(如 "Bus 020 Device 003: 18d1:4ee4")。 + /// Human-readable identifier for logging (e.g. "Bus 020 Device 003: 18d1:4ee4"). [[nodiscard]] virtual std::string_view Describe() const noexcept = 0; }; diff --git a/include/tetherkit/rndis/messages.h b/include/tetherkit/rndis/messages.h index d13c801..0555608 100644 --- a/include/tetherkit/rndis/messages.h +++ b/include/tetherkit/rndis/messages.h @@ -1,16 +1,21 @@ -// RNDIS 控制消息的编解码。 +// Encoding and decoding of RNDIS control messages. // -// 设计取舍:**不用 packed struct 直接映射线格式**,而是「逻辑结构体 + 显式 -// 偏移读写」。理由有三: -// 1. 线格式是小端,packed struct 在大端机上静默出错; -// 2. RNDIS 消息在 USB 缓冲里的起始偏移只保证 4 字节对齐,packed struct 的 -// 成员访问在某些偏移上是未定义行为(-fsanitize=alignment 会报); -// 3. 偏移字段的基准点是「消息起始 + 8」这种反直觉规则(见 protocol.h 文件头 -// 规则 2),用显式的 Encode/Decode 函数能把这个换算集中在一处并加测试, -// 而 struct 映射会让每个使用点都得自己记住 ±8。 +// Design trade-off: **do not map the wire format with packed structs directly**; use +// "logical structs + explicit offset read/write" instead. Three reasons: +// 1. The wire format is little-endian; a packed struct fails silently on big-endian +// hosts; +// 2. RNDIS messages in USB buffers are only guaranteed 4-byte aligned at their start; +// member access on a packed struct is undefined behavior at certain offsets +// (-fsanitize=alignment will flag it); +// 3. The base point of offset fields is the counterintuitive rule "message start + 8" +// (see protocol.h header, rule 2). Explicit Encode/Decode functions concentrate that +// conversion in one place and make it testable, while a struct mapping would force +// every call site to remember the +/-8. // -// 解码函数一律做**完整的边界与自洽性校验**:控制通道的数据来自外部设备, -// 必须假定它可能是恶意或有 bug 的。校验失败返回 Error 而非崩溃或读越界。 +// Decode functions always perform **complete bounds and self-consistency checks**: data +// on the control channel comes from an external device and must be assumed to be +// malicious or buggy. A failed check returns Error rather than crashing or reading out +// of bounds. #pragma once #include @@ -23,23 +28,23 @@ namespace tetherkit::rndis { -/// 6 字节以太网 MAC 地址。 +/// 6-byte Ethernet MAC address. using MacAddress = std::array; -/// 把 MAC 渲染成 "aa:bb:cc:dd:ee:ff"。 +/// Render a MAC as "aa:bb:cc:dd:ee:ff". [[nodiscard]] std::array FormatMac(const MacAddress& mac) noexcept; // ============================================================================= -// 通用头部访问 +// Common header access // ============================================================================= -/// 任意 RNDIS 消息的公共头部。 +/// Common header of any RNDIS message. struct MessageHeader { std::uint32_t message_type = 0; std::uint32_t message_length = 0; }; -/// 读取公共头部。缓冲区不足 8 字节则返回错误。 +/// Read the common header. Returns an error if the buffer is smaller than 8 bytes. [[nodiscard]] Result DecodeMessageHeader(std::span buffer); // ============================================================================= @@ -50,11 +55,13 @@ struct InitializeRequest { std::uint32_t request_id = 0; std::uint32_t major_version = kMajorVersion; std::uint32_t minor_version = kMinorVersion; - /// host 一次能从 bulk IN 接收的最大字节数,用 MaxTransferSizeFor() 算。 + /// Maximum bytes the host can receive from bulk IN in one transfer, computed with + /// MaxTransferSizeFor(). std::uint32_t max_transfer_size = 0; }; -/// 编码 INITIALIZE_MSG。缓冲区至少 kInitializeMsgBytes 字节,返回写入长度。 +/// Encode INITIALIZE_MSG. Buffer must be at least kInitializeMsgBytes; returns bytes +/// written. [[nodiscard]] Result Encode(const InitializeRequest& request, std::span buffer); @@ -65,12 +72,14 @@ struct InitializeComplete { std::uint32_t minor_version = 0; std::uint32_t device_flags = 0; std::uint32_t medium = 0; - /// 设备一次传输能承载的 PACKET_MSG 个数上限。多数 Android gadget 报 1, - /// 打了高通聚合补丁的报 3 或更多。为 0 时按 1 处理。 + /// Maximum PACKET_MSGs per transfer the device can carry. Most Android gadgets report + /// 1; those with Qualcomm aggregation patches report 3 or more. Treated as 1 when 0. std::uint32_t max_packets_per_message = 0; - /// 设备一次能接收的最大字节数 —— 决定 host 侧 TX 聚合的上限。 + /// Maximum bytes the device can receive in one transfer -- determines the host-side TX + /// aggregation limit. std::uint32_t max_transfer_size = 0; - /// host → device 的对齐要求,对齐字节数 = 1 << 此值,合法上界 7。 + /// host -> device alignment requirement, alignment bytes = 1 << this value, legal upper + /// bound 7. std::uint32_t packet_alignment_factor = 0; std::uint32_t af_list_offset = 0; std::uint32_t af_list_size = 0; @@ -78,24 +87,25 @@ struct InitializeComplete { [[nodiscard]] Result DecodeInitializeComplete(std::span buffer); -/// 由 INITIALIZE_CMPLT 推导出的、数据路径真正要用的参数。 +/// Parameters derived from INITIALIZE_CMPLT that the data path actually uses. struct NegotiatedParameters { - std::uint32_t device_max_transfer_size = 0; ///< TX 聚合的字节上限。 - std::uint32_t max_packets_per_message = 1; ///< TX 聚合的包数上限。 - std::uint32_t tx_alignment_bytes = 1; ///< host → device 的每包对齐。 - std::uint32_t mtu = kDefaultMtu; ///< 最终采用的 MTU。 + std::uint32_t device_max_transfer_size = 0; ///< TX aggregation byte limit. + std::uint32_t max_packets_per_message = 1; ///< TX aggregation packet limit. + std::uint32_t tx_alignment_bytes = 1; ///< Per-packet alignment host -> device. + std::uint32_t mtu = kDefaultMtu; ///< Final MTU adopted. bool connectionless = true; }; -/// 校验并归一化 INITIALIZE_CMPLT,得到可直接用于数据路径的参数。 +/// Validate and normalize INITIALIZE_CMPLT to produce parameters ready for the data path. /// -/// 这里集中处理全部已知的设备 quirk: -/// * MaxPacketsPerMessage 为 0 → 当 1; -/// * PacketAlignmentFactor > 7 → 钳到 7(协议违规,否则会算出荒谬的填充); -/// * MaxTransferSize 小于一个满帧(hard_mtu)→ 反推并下调 MTU; -/// 太小(<= 58,装不下头部)→ 报错; -/// * MaxTransferSize 报出 8KB/16KB 巨帧(WinCE / Windows Mobile 的习惯)→ -/// 不盲从,钳到我们自己的缓冲上限。 +/// All known device quirks are handled here: +/// * MaxPacketsPerMessage is 0 -> treat as 1; +/// * PacketAlignmentFactor > 7 -> clamp to 7 (protocol violation, otherwise absurd +/// padding lengths would be computed); +/// * MaxTransferSize smaller than one full frame (hard_mtu) -> derive and lower the +/// MTU; too small (<= 58, cannot fit a header) -> error; +/// * MaxTransferSize reports 8KB/16KB jumbo frames (WinCE / Windows Mobile habit) -> +/// do not follow blindly, clamp to our own buffer limit. [[nodiscard]] Result Negotiate(const InitializeComplete& complete, std::uint32_t requested_mtu, std::uint32_t host_transfer_size_limit); @@ -104,7 +114,8 @@ struct NegotiatedParameters { // HALT // ============================================================================= -/// 编码 HALT_MSG。**设备不会回复**,发完即可认为进入 uninitialized。 +/// Encode HALT_MSG. **The device does not reply**; once sent it can be considered +/// uninitialized. [[nodiscard]] Result EncodeHalt(std::uint32_t request_id, std::span buffer); @@ -115,23 +126,25 @@ struct NegotiatedParameters { struct QueryRequest { std::uint32_t request_id = 0; Oid oid = Oid::kGenSupportedList; - /// 期望的响应字节数。 + /// Expected response size in bytes. /// - /// 对**定长** OID 必须 ≥ 期望响应长度,并在消息尾部补上同样多的零字节; - /// 对**变长** OID 必须为 0。这条未文档化的要求来自微软 ActiveSync 实现, - /// 违反会得到 RNDIS_STATUS_INVALID_LENGTH。Encode 会按 IsVariableLengthOid() - /// 自动纠正,调用方填 0 即可让它自己决定。 + /// For **fixed-length** OIDs this must be >= the expected response length, and the + /// same number of zero bytes must be appended at the end of the message; for + /// **variable-length** OIDs it must be 0. This undocumented requirement comes from the + /// Microsoft ActiveSync implementation; violating it yields + /// RNDIS_STATUS_INVALID_LENGTH. Encode corrects this automatically based on + /// IsVariableLengthOid(), so callers can pass 0 and let it decide. std::uint32_t expected_response_bytes = 0; }; -/// 编码 QUERY_MSG(含按 OID 类型自动决定的尾部填充)。 +/// Encode QUERY_MSG (including trailing padding chosen automatically by OID type). [[nodiscard]] Result Encode(const QueryRequest& request, std::span buffer); struct QueryComplete { std::uint32_t request_id = 0; std::uint32_t status = 0; - /// 指向 `buffer` 内部的信息缓冲区视图,生命周期同 `buffer`。 + /// View of the information buffer inside `buffer`, with the same lifetime as `buffer`. std::span information; }; @@ -143,10 +156,11 @@ struct SetRequest { std::span information; }; -/// 编码 SET_MSG。 +/// Encode SET_MSG. [[nodiscard]] Result Encode(const SetRequest& request, std::span buffer); -/// 便捷入口:SET 一个 LE32 值(最常用,例如设置包过滤)。 +/// Convenience entry point: SET an LE32 value (most common, e.g. setting the packet +/// filter). [[nodiscard]] Result EncodeSetUint32(std::uint32_t request_id, Oid oid, std::uint32_t value, std::span buffer); @@ -162,15 +176,16 @@ struct SetComplete { // RESET // ============================================================================= -/// 编码 RESET_MSG。 +/// Encode RESET_MSG. /// -/// **注意:RESET_MSG 没有 RequestId 字段**(offset 8 是 Reserved),所以无法用 -/// ID 配对响应,同一时刻只能有一个 RESET 在飞。 +/// **Note: RESET_MSG has no RequestId field** (offset 8 is Reserved), so responses +/// cannot be matched by ID and only one RESET may be in flight at a time. [[nodiscard]] Result EncodeReset(std::span buffer); struct ResetComplete { std::uint32_t status = 0; - /// 非零表示寻址信息(包过滤、组播表)在复位中丢失,host 必须重发对应 SET。 + /// Non-zero means addressing information (packet filter, multicast list) was lost in + /// the reset and the host must re-send the corresponding SETs. bool addressing_reset = false; }; @@ -190,10 +205,11 @@ struct KeepAliveComplete { [[nodiscard]] Result DecodeKeepAliveComplete(std::span buffer); -/// 编码 KEEPALIVE_CMPLT。 +/// Encode KEEPALIVE_CMPLT. /// -/// 需要它是因为**设备也可以主动发 KEEPALIVE_MSG**,此时 host 必须回复 -/// KEEPALIVE_CMPLT,否则设备可能认为 host 已死并断开。 +/// Needed because **the device can also send KEEPALIVE_MSG proactively**; in that case +/// the host must reply with KEEPALIVE_CMPLT, otherwise the device may assume the host is +/// dead and disconnect. [[nodiscard]] Result EncodeKeepAliveComplete(std::uint32_t request_id, std::uint32_t status, std::span buffer); @@ -204,38 +220,44 @@ struct KeepAliveComplete { struct IndicateStatus { std::uint32_t status = 0; - /// 状态缓冲区视图;多数情况为空(MEDIA_CONNECT/DISCONNECT 不带负载)。 + /// View of the status buffer; empty in most cases (MEDIA_CONNECT/DISCONNECT carry no + /// payload). std::span status_buffer; - /// 若状态缓冲区恰好是 8 字节的 RNDIS_Diagnostic_Info,这两个字段被填充。 + /// If the status buffer is exactly the 8-byte RNDIS_Diagnostic_Info, these fields are + /// populated. bool has_diagnostic_info = false; std::uint32_t diagnostic_status = 0; std::uint32_t diagnostic_error_offset = 0; }; -/// 解码 INDICATE_STATUS_MSG。 +/// Decode INDICATE_STATUS_MSG. /// -/// **StatusBufferOffset 的基准点在规范里是矛盾的**(MS 文档说消息起始,而同族 -/// 的 QUERY/SET 都是消息起始 +8)。本实现按以下顺序容错: -/// 1. 若长度为 0 —— 无负载,直接成功(绝大多数情况); -/// 2. 先按「基准点 = 消息起始 + 8」解释,落在消息范围内则采用; -/// 3. 否则按「基准点 = 消息起始」解释; -/// 4. 两种都越界 —— 丢弃状态缓冲区但**仍返回成功**,因为 status 本身有用, -/// 不能因为一个可选负载解析不了就断开链路。 +/// **The base point of StatusBufferOffset is contradictory in the specification** (MS +/// docs say message start, while sibling QUERY/SET use message start + 8). This +/// implementation is tolerant in the following order: +/// 1. If the length is 0 -- no payload, succeed directly (the vast majority of cases); +/// 2. Interpret with base "message start + 8" first; adopt if it falls within the +/// message; +/// 3. Otherwise interpret with base "message start"; +/// 4. If both are out of bounds -- discard the status buffer but **still return +/// success**, because the status itself is useful and a link must not be torn down +/// just because an optional payload cannot be parsed. [[nodiscard]] Result DecodeIndicateStatus(std::span buffer); // ============================================================================= -// OID 负载解析 +// OID payload parsing // ============================================================================= -/// 从 QUERY_CMPLT 的信息缓冲区里取一个 LE32。 +/// Read an LE32 from a QUERY_CMPLT information buffer. [[nodiscard]] Result ParseUint32(std::span information); -/// 从信息缓冲区里取一个计数器值。 +/// Read a counter value from an information buffer. /// -/// 统计类 OID(OID_GEN_XMIT_OK 等)可能返回 4 或 8 字节,两者都必须接受。 +/// Statistics OIDs (OID_GEN_XMIT_OK, etc.) may return 4 or 8 bytes; both must be +/// accepted. [[nodiscard]] Result ParseCounter(std::span information); -/// 从信息缓冲区里取 6 字节 MAC。 +/// Read a 6-byte MAC from an information buffer. [[nodiscard]] Result ParseMac(std::span information); } // namespace tetherkit::rndis diff --git a/include/tetherkit/rndis/packet_codec.h b/include/tetherkit/rndis/packet_codec.h index 72b0e6f..c6c890a 100644 --- a/include/tetherkit/rndis/packet_codec.h +++ b/include/tetherkit/rndis/packet_codec.h @@ -1,20 +1,23 @@ -// REMOTE_NDIS_PACKET_MSG 的编解码 —— **数据热路径**。 +// Encoding and decoding of REMOTE_NDIS_PACKET_MSG -- **data hot path**. // -// 这是全项目唯一每帧都要执行的协议代码,因此: -// * 全部 inline 放在头文件里,让编译器能内联进桥接层的循环; -// * 不返回 std::expected(会分配 std::string)、不抛异常、不分配内存; -// 错误通过枚举 + 计数器表达; -// * 全部 noexcept。 +// This is the only protocol code in the project executed for every frame, so: +// * Everything is inline in the header so the compiler can inline it into the bridge +// loop; +// * No std::expected (allocates std::string), no exceptions, no memory allocation; +// errors are expressed through enums and counters; +// * Everything is noexcept. // -// 两个方向的形态不同,务必分清: +// The two directions have different shapes and must not be confused: // -// device → host(PacketMessageReader): -// 一次 bulk IN 传输里串着 1~N 个 PACKET_MSG,按 MessageLength 步进遍历。 -// 必须容忍尾部垃圾(设备也会用「多补 1 字节」的手法规避 ZLP)。 +// device -> host (PacketMessageReader): +// One bulk IN transfer carries 1..N PACKET_MSGs, traversed by stepping over +// MessageLength. Trailing garbage must be tolerated (devices also use "add 1 extra +// byte" to avoid ZLP). // -// host → device(PacketMessageWriter): -// 把多帧聚合进一次 bulk OUT,受三重约束:设备的 MaxTransferSize、 -// MaxPacketsPerMessage、以及 PacketAlignmentFactor 决定的每包对齐。 +// host -> device (PacketMessageWriter): +// Aggregate multiple frames into one bulk OUT transfer, constrained three ways: the +// device's MaxTransferSize, MaxPacketsPerMessage, and per-packet alignment +// determined by PacketAlignmentFactor. #pragma once #include @@ -29,35 +32,35 @@ namespace tetherkit::rndis { // ============================================================================= -// 解码:device → host +// Decode: device -> host // ============================================================================= -/// 遍历一次 bulk IN 传输的结果。 +/// Result of iterating over one bulk IN transfer. enum class ReadOutcome : std::uint8_t { - kFrame, ///< 成功取出一帧。 - kEndOfTransfer, ///< 正常结束(可能有被容忍的尾部填充)。 - kMalformed, ///< 遇到非法数据,必须停止解析本次传输。 + kFrame, ///< Successfully extracted a frame. + kEndOfTransfer, ///< Normal end (there may be tolerated trailing padding). + kMalformed, ///< Malformed data encountered; parsing of this transfer must stop. }; -/// 非法数据的具体原因,用于统计与排障。 +/// Specific reason for malformed data, used for statistics and troubleshooting. enum class MalformedReason : std::uint8_t { kNone, - kNotPacketMessage, ///< MessageType 不是 REMOTE_NDIS_PACKET_MSG。 - kMessageTooShort, ///< MessageLength < 44。 - kMessageOverruns, ///< MessageLength 超出剩余缓冲。 - kDataOutOfBounds, ///< DataOffset + DataLength 超出 MessageLength。 - kFrameTooShort, ///< DataLength < 14,装不下以太头。 - kFrameTooLong, ///< DataLength 超过本机允许的单帧上限。 + kNotPacketMessage, ///< MessageType is not REMOTE_NDIS_PACKET_MSG. + kMessageTooShort, ///< MessageLength < 44. + kMessageOverruns, ///< MessageLength exceeds the remaining buffer. + kDataOutOfBounds, ///< DataOffset + DataLength exceeds MessageLength. + kFrameTooShort, ///< DataLength < 14, cannot hold an Ethernet header. + kFrameTooLong, ///< DataLength exceeds the per-frame limit allowed by this host. }; -/// 从一次 bulk IN 传输缓冲里逐帧取出以太帧。 +/// Extract Ethernet frames one by one from a single bulk IN transfer buffer. /// -/// 用法: +/// Usage: /// ``` /// PacketMessageReader reader(transfer_bytes, max_frame_bytes); /// std::span frame; /// while (reader.Next(frame) == ReadOutcome::kFrame) { -/// ... 处理 frame ... +/// ... handle frame ... /// } /// ``` class PacketMessageReader { @@ -65,15 +68,19 @@ class PacketMessageReader { PacketMessageReader(std::span transfer, std::uint32_t max_frame_bytes) noexcept : transfer_(transfer), max_frame_bytes_(max_frame_bytes) {} - /// 取下一帧。返回 kFrame 时 `frame` 指向 transfer 内部,无拷贝。 + /// Take the next frame. When kFrame is returned, `frame` points inside the transfer + /// with no copy. [[nodiscard]] ReadOutcome Next(std::span& frame) noexcept { const std::size_t remaining = transfer_.size() - cursor_; - // 剩余不足一个完整头部:按尾部填充处理,正常结束。 + // Not enough remaining for a complete header: treat as trailing padding and end + // normally. // - // 这是**必须**的容忍:RNDIS 规定 host 与 device 都不发 ZLP,而是在传输长度 - // 恰为端点最大包长整数倍时多补 1 个字节使其变成短包。那个字节落在所有 - // MessageLength 之外,若把它当错误就会在每 2048 字节的传输上误报一次。 + // This tolerance is **required**: RNDIS specifies that neither host nor device sends + // ZLPs; instead, when the transfer length is an exact integer multiple of the + // endpoint's max packet size, one extra byte is appended to make it a short packet. + // That byte falls outside all MessageLengths; treating it as an error would + // misreport once per 2048-byte transfer. if (remaining < kPacketMsgHeaderBytes) { trailing_padding_bytes_ = static_cast(remaining); return ReadOutcome::kEndOfTransfer; @@ -97,8 +104,8 @@ class PacketMessageReader { const std::uint32_t data_offset = LoadLe32(message + kPacketDataOffsetOffset); const std::uint32_t data_length = LoadLe32(message + kPacketDataLengthOffset); - // 绝对偏移 = 8 + DataOffset(protocol.h 规则 2)。 - // 用 64 位运算避免设备汇报巨大值时溢出。 + // Absolute offset = 8 + DataOffset (protocol.h rule 2). + // Use 64-bit arithmetic to avoid overflow when the device reports a huge value. const std::uint64_t data_begin = static_cast(kOffsetFieldBase) + data_offset; const std::uint64_t data_end = data_begin + data_length; if (data_end > message_length) [[unlikely]] { @@ -121,12 +128,12 @@ class PacketMessageReader { [[nodiscard]] MalformedReason Reason() const noexcept { return reason_; } - /// 被当作填充忽略掉的尾部字节数(正常应为 0 或 1)。 + /// Number of trailing bytes ignored as padding (normally 0 or 1). [[nodiscard]] std::uint32_t TrailingPaddingBytes() const noexcept { return trailing_padding_bytes_; } - /// 已消费的字节数,用于校验是否把整个传输都解析完了。 + /// Bytes consumed, used to verify that the entire transfer was parsed. [[nodiscard]] std::size_t BytesConsumed() const noexcept { return cursor_; } private: @@ -143,10 +150,11 @@ class PacketMessageReader { MalformedReason reason_ = MalformedReason::kNone; }; -/// MalformedReason 对应的文案标识。 +/// Message identifier for a MalformedReason. /// -/// 返回标识而不是现成的字符串,是为了保住 constexpr —— 真正的文字要按用户当前 -/// 语言渲染,调用方用 `Text(MalformedReasonMessage(r))` 取。 +/// Returns an identifier instead of a ready-made string to preserve constexpr -- the +/// actual text must be rendered in the user's current language; callers take it with +/// `Text(MalformedReasonMessage(r))`. [[nodiscard]] constexpr Msg MalformedReasonMessage(MalformedReason reason) noexcept { switch (reason) { case MalformedReason::kNone: @@ -167,31 +175,34 @@ class PacketMessageReader { return Msg::kRndisMalformedUnknown; } -/// MalformedReason 的可读名字(按当前语言)。 +/// Human-readable name for MalformedReason (in the current language). [[nodiscard]] inline std::string_view MalformedReasonName(MalformedReason reason) noexcept { return Text(MalformedReasonMessage(reason)); } // ============================================================================= -// 编码:host → device +// Encode: host -> device // ============================================================================= -/// 把多个以太帧聚合成一次 bulk OUT 传输。 +/// Aggregate multiple Ethernet frames into one bulk OUT transfer. /// -/// 对齐处理的细节(这是最容易写错的地方): -/// RNDIS 规定「除最后一个之外,每个 PACKET_MSG 的 MessageLength 都包含尾部 -/// 对齐填充;最后一个不含外部填充」。因此本实现在追加**下一个**消息时,才 -/// 回头把**上一个**消息的 MessageLength 扩大以吞掉中间的填充字节 —— -/// 这样最后一个消息的 MessageLength 天然就是 44 + 帧长,完全符合规范。 +/// Alignment detail (this is the easiest place to get wrong): +/// RNDIS specifies that "for every PACKET_MSG except the last, MessageLength includes +/// trailing alignment padding; the last does not include external padding". Therefore +/// this implementation expands the **previous** message's MessageLength to absorb the +/// intermediate padding bytes only when appending the **next** message -- so the last +/// message's MessageLength is naturally 44 + frame length, fully compliant with the +/// specification. class PacketMessageWriter { public: - /// @param transfer 输出缓冲(通常是一个 libusb 传输的 buffer) - /// @param limits 设备协商出的聚合上限 - /// @param endpoint_max_packet bulk OUT 端点的 wMaxPacketSize,用于规避 ZLP + /// @param transfer Output buffer (usually a libusb transfer buffer). + /// @param limits Aggregation limits negotiated with the device. + /// @param endpoint_max_packet wMaxPacketSize of the bulk OUT endpoint, used to avoid + /// ZLP. struct Limits { - std::uint32_t max_transfer_bytes = 0; ///< 设备的 MaxTransferSize。 - std::uint32_t max_messages = 1; ///< 设备的 MaxPacketsPerMessage。 - std::uint32_t alignment_bytes = 1; ///< 1 << PacketAlignmentFactor。 + std::uint32_t max_transfer_bytes = 0; ///< Device MaxTransferSize. + std::uint32_t max_messages = 1; ///< Device MaxPacketsPerMessage. + std::uint32_t alignment_bytes = 1; ///< 1 << PacketAlignmentFactor. }; PacketMessageWriter(std::span transfer, const Limits& limits, @@ -200,8 +211,8 @@ class PacketMessageWriter { endpoint_max_packet_(endpoint_max_packet), max_messages_(limits.max_messages == 0 ? 1 : limits.max_messages), alignment_bytes_(limits.alignment_bytes == 0 ? 1 : limits.alignment_bytes) { - // 有效容量取「缓冲区大小」与「设备 MaxTransferSize」的较小值, - // 再预留 1 字节给可能需要的 ZLP 规避填充。 + // Effective capacity is the smaller of "buffer size" and "device MaxTransferSize", + // minus 1 byte reserved for possible ZLP-avoidance padding. const std::size_t device_limit = limits.max_transfer_bytes == 0 ? transfer.size() : static_cast(limits.max_transfer_bytes); @@ -209,9 +220,11 @@ class PacketMessageWriter { capacity_ = usable > kZlpPadReserve ? usable - kZlpPadReserve : 0; } - /// 尝试追加一帧。返回 false 表示本批次已满,调用方应先提交再重试。 + /// Try to append a frame. Returns false if this batch is full and the caller should + /// submit first and retry. /// - /// 帧长必须 >= 14;调用方负责保证(BPF 读上来的帧一定满足)。 + /// Frame length must be >= 14; the caller is responsible for guaranteeing this (frames + /// read from BPF always satisfy it). [[nodiscard]] bool TryAppend(std::span frame) noexcept { if (message_count_ >= max_messages_) [[unlikely]] { return false; @@ -219,7 +232,8 @@ class PacketMessageWriter { const auto frame_length = static_cast(frame.size()); - // 若已有消息,先算出为满足对齐需要插入多少填充。 + // If there is already a message, compute how much padding is needed to satisfy + // alignment. const std::size_t aligned_cursor = message_count_ == 0 ? cursor_ : AlignUp(cursor_, alignment_bytes_); const std::size_t needed = aligned_cursor + kPacketMsgHeaderBytes + frame_length; @@ -227,7 +241,8 @@ class PacketMessageWriter { return false; } - // 把填充字节归入**上一个**消息的 MessageLength(见类文档注释)。 + // Attribute padding bytes to the **previous** message's MessageLength (see class + // documentation). if (aligned_cursor != cursor_) { const auto padding = static_cast(aligned_cursor - cursor_); std::memset(transfer_.data() + cursor_, 0, padding); @@ -243,10 +258,11 @@ class PacketMessageWriter { StoreLe32(message + kMessageTypeOffset, ToRaw(MessageType::kPacket)); StoreLe32(message + kMessageLengthOffset, message_length); - // DataOffset 是 36 而不是 44 —— 基准点是消息起始 +8。 + // DataOffset is 36, not 44 -- base point is message start + 8. StoreLe32(message + kPacketDataOffsetOffset, kPacketInlineDataOffset); StoreLe32(message + kPacketDataLengthOffset, frame_length); - // 其余字段(OOB、per-packet info、VcHandle、Reserved)本项目一律为 0。 + // Remaining fields (OOB, per-packet info, VcHandle, Reserved) are always 0 in this + // project. std::memset(message + kPacketOobDataOffsetOffset, 0, kPacketMsgHeaderBytes - kPacketOobDataOffsetOffset); @@ -259,12 +275,14 @@ class PacketMessageWriter { return true; } - /// 结束本批次,返回应提交给 libusb 的字节数。 + /// Finish this batch and return the byte count to submit to libusb. /// - /// 会在必要时追加 1 个 0x00 字节以规避 ZLP:RNDIS 明确要求 host **不得**发 - /// 零长度包,而当传输长度恰为端点 wMaxPacketSize 的整数倍时,USB 主机控制器 - /// 需要一个短包来标记传输结束。Linux 的 usbnet 就是这么做的 —— 多出的字节在 - /// 所有 MessageLength 之外,设备必须容忍尾部垃圾。 + /// Appends 1 byte 0x00 when necessary to avoid a ZLP: RNDIS explicitly requires the + /// host to **never** send a zero-length packet, and when the transfer length is an + /// exact integer multiple of the endpoint wMaxPacketSize, the USB host controller needs + /// a short packet to mark the end of the transfer. This is what Linux usbnet does -- + /// the extra byte falls outside all MessageLengths and the device must tolerate + /// trailing garbage. [[nodiscard]] std::uint32_t Finish() noexcept { if (cursor_ == 0) { return 0; @@ -279,15 +297,17 @@ class PacketMessageWriter { [[nodiscard]] std::uint32_t MessageCount() const noexcept { return message_count_; } - /// 已聚合的以太帧净荷字节数(不含 RNDIS 头与填充),用于统计吞吐。 + /// Aggregated Ethernet frame payload bytes (excluding RNDIS headers and padding), for + /// throughput statistics. [[nodiscard]] std::uint64_t PayloadBytes() const noexcept { return payload_bytes_; } [[nodiscard]] bool ZlpPaddingAdded() const noexcept { return zlp_padding_added_; } [[nodiscard]] bool Empty() const noexcept { return message_count_ == 0; } - /// 当前批次还能容纳的最大帧长(0 表示装不下任何帧了)。 - /// 调用方可用它避免「取出一帧却发现放不进去」的回退逻辑。 + /// Maximum frame length the current batch can still hold (0 means no more frames fit). + /// Callers can use this to avoid "take out a frame only to find it does not fit" + /// backtracking logic. [[nodiscard]] std::uint32_t RemainingFrameCapacity() const noexcept { if (message_count_ >= max_messages_) { return 0; @@ -299,7 +319,7 @@ class PacketMessageWriter { } private: - /// 为 ZLP 规避预留的字节数。 + /// Bytes reserved for ZLP avoidance. static constexpr std::size_t kZlpPadReserve = 1; std::span transfer_; diff --git a/include/tetherkit/rndis/protocol.h b/include/tetherkit/rndis/protocol.h index c3e53c0..0d0d329 100644 --- a/include/tetherkit/rndis/protocol.h +++ b/include/tetherkit/rndis/protocol.h @@ -1,28 +1,32 @@ -// RNDIS(Remote NDIS)线格式常量与字段布局。 +// RNDIS (Remote NDIS) wire-format constants and field layout. // -// 本文件是全项目 RNDIS 协议常量的**唯一来源**,不允许在别处重复定义。 -// 全部数值来自 Microsoft RNDIS 规范,并与 Linux 内核实现交叉验证过 -// (drivers/net/usb/rndis_host.c、include/linux/usb/rndis_host.h、 -// drivers/usb/gadget/function/rndis.c、include/linux/usb/cdc.h)。 -// 详细的字段表与出处见 docs/RNDIS-PROTOCOL.md。 +// This file is the **single source** of RNDIS protocol constants for the project; +// duplicates elsewhere are not allowed. All values come from the Microsoft RNDIS +// specification and have been cross-checked against the Linux kernel implementation +// (drivers/net/usb/rndis_host.c, include/linux/usb/rndis_host.h, +// drivers/usb/gadget/function/rndis.c, include/linux/usb/cdc.h). +// See docs/RNDIS-PROTOCOL.md for detailed field tables and references. // -// ★ 三条最容易搞错、必须牢记的规则 ★ +// Three rules that are easiest to get wrong and must be remembered: // -// 1. **所有多字节字段都是小端**(协议源自 Windows NDIS),一律用 byte_order.h -// 的 LoadLe32 / StoreLe32 访问,禁止用 packed struct 直接映射。 +// 1. **All multi-byte fields are little-endian** (the protocol comes from Windows NDIS). +// Always access them with LoadLe32 / StoreLe32 from byte_order.h; never map them +// directly with a packed struct. // -// 2. **偏移字段的基准点不是消息起始,而是消息起始 + 8。** -// PACKET_MSG 的 DataOffset / OOBDataOffset / PerPacketInfoOffset,以及 -// QUERY/SET/QUERY_CMPLT 的 InformationBufferOffset,基准点都是「该组偏移 -// 字段所属区块的起始」,即消息起始 + 8 字节处。 -// 绝对偏移 = 8 + 字段值 -// 所以「数据紧跟 44 字节 PACKET_MSG 头」时 DataOffset = 36,而不是 44。 -// 这是实现 RNDIS 时的第一号陷阱。 +// 2. **The base point of offset fields is not the message start, but message start + 8.** +// For DataOffset / OOBDataOffset / PerPacketInfoOffset in PACKET_MSG, and for +// InformationBufferOffset in QUERY/SET/QUERY_CMPLT, the base point is "the start of +// the block those offset fields belong to", i.e. message start + 8 bytes. +// absolute offset = 8 + field value +// So when "data immediately follows the 44-byte PACKET_MSG header", DataOffset = 36, +// not 44. This is the number-one trap when implementing RNDIS. // -// 3. **INDICATE_STATUS_MSG 的 StatusBufferOffset 是例外**:MS 文档写它的基准点 -// 是消息起始(+0),与上面的 +8 约定不一致。这是规范自身的矛盾。实践中该 -// 字段绝大多数为 0(MEDIA_CONNECT / MEDIA_DISCONNECT 不带 status buffer), -// 因此解析时必须两种解释都能容忍,且不能因它出错就断开连接。 +// 3. **INDICATE_STATUS_MSG's StatusBufferOffset is an exception**: MS docs state its base +// point is the message start (+0), inconsistent with the +8 convention above. This is +// a contradiction in the specification itself. In practice the field is almost always +// 0 (MEDIA_CONNECT / MEDIA_DISCONNECT carry no status buffer), so parsing must +// tolerate both interpretations and must not tear down the link because of a parse +// error. #pragma once #include @@ -33,46 +37,51 @@ namespace tetherkit::rndis { // ============================================================================= -// 版本与超时 +// Version and timeouts // ============================================================================= -/// host 在 INITIALIZE_MSG 中宣称的协议版本。 +/// Protocol version the host declares in INITIALIZE_MSG. inline constexpr std::uint32_t kMajorVersion = 1; inline constexpr std::uint32_t kMinorVersion = 0; -/// 控制通道超时。 +/// Control channel timeout. /// -/// 规范给的 ControlTimeoutPeriod 是 10 秒,但 Linux 因为微软 ActiveSync 实现的 -/// 行为把它收缩到 5 秒。这里取 5 秒:设备真要 10 秒才回,链路已经不可用了。 +/// The specification gives ControlTimeoutPeriod as 10 seconds, but Linux shrinks it to 5 +/// seconds because of the behavior of Microsoft's ActiveSync implementation. We take 5 +/// seconds here: if a device really needs 10 seconds to reply, the link is already +/// unusable. inline constexpr std::uint32_t kControlTimeoutMillis = 5'000; -/// 保活周期。规范的 KeepAliveTimeoutPeriod 是 5 秒,且语义是「距上次从设备 -/// 收到任何消息已过 5 秒」才发,而不是无条件每 5 秒发一次。 +/// Keep-alive period. The specification's KeepAliveTimeoutPeriod is 5 seconds, and the +/// semantics are "send only after 5 seconds have passed since the last message received +/// from the device", not unconditionally every 5 seconds. inline constexpr std::uint32_t kKeepAliveTimeoutMillis = 5'000; -/// 控制消息缓冲区大小。 +/// Control message buffer size. /// -/// 规范要求 host 侧至少 1024 字节(建议 GET_ENCAPSULATED_RESPONSE 的 -/// wLength 用 0x400)。Windows 实际用 1025 这个奇怪的值,Linux 照抄。 -/// 这里取 1536 留出余量 —— OID_GEN_SUPPORTED_LIST 的返回值可能很长。 +/// The specification requires at least 1024 bytes on the host side (it suggests using +/// 0x400 for wLength of GET_ENCAPSULATED_RESPONSE). Windows actually uses the odd value +/// 1025 and Linux copies it. We take 1536 to leave headroom -- the return value of +/// OID_GEN_SUPPORTED_LIST can be long. inline constexpr std::uint32_t kControlBufferBytes = 1536; // ============================================================================= -// 消息类型码 +// Message type codes // ============================================================================= -/// 完成消息 = 请求消息 | 此位。 +/// Completion message = request message | this bit. inline constexpr std::uint32_t kMessageCompletionFlag = 0x8000'0000U; enum class MessageType : std::uint32_t { - kPacket = 0x0000'0001U, ///< 数据通道:承载以太帧。 - kInitialize = 0x0000'0002U, ///< host → device:初始化协商。 - kHalt = 0x0000'0003U, ///< host → device:终止,**设备不回复**。 - kQuery = 0x0000'0004U, ///< host → device:读 OID。 - kSet = 0x0000'0005U, ///< host → device:写 OID。 - kReset = 0x0000'0006U, ///< host → device:软复位。 - kIndicateStatus = 0x0000'0007U, ///< device → host:**无 RequestId、无需回复**。 - kKeepAlive = 0x0000'0008U, ///< 双向都可发起。 + kPacket = 0x0000'0001U, ///< Data channel: carries Ethernet frames. + kInitialize = 0x0000'0002U, ///< host -> device: initialization negotiation. + kHalt = 0x0000'0003U, ///< host -> device: halt, **device does not reply**. + kQuery = 0x0000'0004U, ///< host -> device: read OID. + kSet = 0x0000'0005U, ///< host -> device: write OID. + kReset = 0x0000'0006U, ///< host -> device: soft reset. + kIndicateStatus = 0x0000'0007U, ///< device -> host: **no RequestId, no reply + ///< required**. + kKeepAlive = 0x0000'0008U, ///< Either side may initiate. kInitializeComplete = kInitialize | kMessageCompletionFlag, // 0x80000002 kQueryComplete = kQuery | kMessageCompletionFlag, // 0x80000004 @@ -80,12 +89,13 @@ enum class MessageType : std::uint32_t { kResetComplete = kReset | kMessageCompletionFlag, // 0x80000006 kKeepAliveComplete = kKeepAlive | kMessageCompletionFlag, // 0x80000008 - /// 私有总线消息,本项目不使用,仅用于识别并忽略。 + /// Private bus message, unused by this project; listed only to identify and ignore. kBus = 0xFF00'0001U, }; -/// 面向连接(CONDIS)消息族。802.3 以太网设备**不会**用到, -/// 这里列出仅为了在收到时能明确报告「不支持面向连接设备」而非报「未知消息」。 +/// Connection-oriented (CONDIS) message family. 802.3 Ethernet devices **will not** use +/// these; listed here only so that receiving one can be reported explicitly as +/// "connection-oriented device not supported" rather than "unknown message". enum class CondisMessageType : std::uint32_t { kCreateVc = 0x0000'8001U, kDeleteVc = 0x0000'8002U, @@ -94,55 +104,56 @@ enum class CondisMessageType : std::uint32_t { kIndicateStatus = 0x0000'8007U, }; -/// 把任意线格式枚举转回它的原始 LE32 数值。 +/// Convert any wire-format enum back to its raw LE32 value. /// -/// 约束到「底层类型必须正好是 uint32_t」,这样一旦有人不小心把某个协议枚举的 -/// 底层类型改小,这里会直接编译失败,而不是静默写坏线格式。 +/// Constrained to "underlying type must be exactly uint32_t", so if someone accidentally +/// shrinks the underlying type of a protocol enum, this fails to compile directly +/// instead of silently corrupting the wire format. template requires std::is_enum_v && std::same_as, std::uint32_t> [[nodiscard]] constexpr std::uint32_t ToRaw(E value) noexcept { return static_cast(value); } -/// 是否为完成类消息。 +/// Whether this is a completion message. [[nodiscard]] constexpr bool IsCompletion(std::uint32_t message_type) noexcept { return (message_type & kMessageCompletionFlag) != 0; } -/// 是否属于 CONDIS(面向连接)消息族。 +/// Whether this belongs to the CONDIS (connection-oriented) message family. [[nodiscard]] constexpr bool IsCondis(std::uint32_t message_type) noexcept { return (message_type & ~kMessageCompletionFlag) >= 0x0000'8000U && (message_type & ~kMessageCompletionFlag) <= 0x0000'8FFFU; } -/// 消息类型的可读名字;未知类型返回空。 +/// Human-readable name for a message type; empty for unknown types. [[nodiscard]] std::string_view MessageTypeName(std::uint32_t message_type) noexcept; // ============================================================================= -// 消息长度与字段偏移 +// Message lengths and field offsets // -// 所有字段都是 LE32。下面的 kXxxOffset 常量是**消息内的字节偏移**, -// 供 LoadLe32(buffer + kXxxOffset) 直接使用。 +// All fields are LE32. The kXxxOffset constants below are **byte offsets within the +// message**, for direct use with LoadLe32(buffer + kXxxOffset). // ============================================================================= -/// 每条 RNDIS 消息共有的前两个字段。 +/// The first two fields common to every RNDIS message. inline constexpr std::uint32_t kMessageTypeOffset = 0; inline constexpr std::uint32_t kMessageLengthOffset = 4; -/// 最短的合法 RNDIS 消息就是 Type + Length。 +/// The shortest valid RNDIS message is just Type + Length. inline constexpr std::uint32_t kMessageHeaderBytes = 8; -/// 「偏移字段」的基准点:消息起始 + 8。见文件头第 2 条规则。 +/// Base point for "offset fields": message start + 8. See header rule 2. inline constexpr std::uint32_t kOffsetFieldBase = 8; -// ---- REMOTE_NDIS_INITIALIZE_MSG(24 字节)---- +// ---- REMOTE_NDIS_INITIALIZE_MSG (24 bytes) ---- inline constexpr std::uint32_t kInitializeMsgBytes = 24; inline constexpr std::uint32_t kInitializeRequestIdOffset = 8; inline constexpr std::uint32_t kInitializeMajorVersionOffset = 12; inline constexpr std::uint32_t kInitializeMinorVersionOffset = 16; inline constexpr std::uint32_t kInitializeMaxTransferSizeOffset = 20; -// ---- REMOTE_NDIS_INITIALIZE_CMPLT(52 字节)---- +// ---- REMOTE_NDIS_INITIALIZE_CMPLT (52 bytes) ---- inline constexpr std::uint32_t kInitializeCmpltBytes = 52; inline constexpr std::uint32_t kInitializeCmpltRequestIdOffset = 8; inline constexpr std::uint32_t kInitializeCmpltStatusOffset = 12; @@ -156,11 +167,11 @@ inline constexpr std::uint32_t kInitializeCmpltPacketAlignmentFactorOffset = 40; inline constexpr std::uint32_t kInitializeCmpltAfListOffsetOffset = 44; inline constexpr std::uint32_t kInitializeCmpltAfListSizeOffset = 48; -// ---- REMOTE_NDIS_HALT_MSG(12 字节,设备不回复)---- +// ---- REMOTE_NDIS_HALT_MSG (12 bytes, device does not reply) ---- inline constexpr std::uint32_t kHaltMsgBytes = 12; inline constexpr std::uint32_t kHaltRequestIdOffset = 8; -// ---- REMOTE_NDIS_QUERY_MSG / SET_MSG(头部 28 字节)---- +// ---- REMOTE_NDIS_QUERY_MSG / SET_MSG (28-byte header) ---- inline constexpr std::uint32_t kQuerySetHeaderBytes = 28; inline constexpr std::uint32_t kQuerySetRequestIdOffset = 8; inline constexpr std::uint32_t kQuerySetOidOffset = 12; @@ -168,59 +179,62 @@ inline constexpr std::uint32_t kQuerySetInfoBufferLengthOffset = 16; inline constexpr std::uint32_t kQuerySetInfoBufferOffsetOffset = 20; inline constexpr std::uint32_t kQuerySetDeviceVcHandleOffset = 24; -/// 「信息缓冲区紧跟 28 字节头部」时 InformationBufferOffset 应填的值。 +/// Value to write for InformationBufferOffset when "the information buffer immediately +/// follows the 28-byte header". inline constexpr std::uint32_t kQuerySetInlineInfoOffset = kQuerySetHeaderBytes - kOffsetFieldBase; static_assert(kQuerySetInlineInfoOffset == 20); -// ---- REMOTE_NDIS_QUERY_CMPLT(头部 24 字节)---- +// ---- REMOTE_NDIS_QUERY_CMPLT (24-byte header) ---- inline constexpr std::uint32_t kQueryCmpltHeaderBytes = 24; inline constexpr std::uint32_t kQueryCmpltRequestIdOffset = 8; inline constexpr std::uint32_t kQueryCmpltStatusOffset = 12; inline constexpr std::uint32_t kQueryCmpltInfoBufferLengthOffset = 16; inline constexpr std::uint32_t kQueryCmpltInfoBufferOffsetOffset = 20; -/// 「信息缓冲区紧跟 24 字节头部」时 InformationBufferOffset 的值。 +/// Value of InformationBufferOffset when "the information buffer immediately follows the +/// 24-byte header". inline constexpr std::uint32_t kQueryCmpltInlineInfoOffset = kQueryCmpltHeaderBytes - kOffsetFieldBase; static_assert(kQueryCmpltInlineInfoOffset == 16); -// ---- REMOTE_NDIS_SET_CMPLT(16 字节)---- +// ---- REMOTE_NDIS_SET_CMPLT (16 bytes) ---- inline constexpr std::uint32_t kSetCmpltBytes = 16; inline constexpr std::uint32_t kSetCmpltRequestIdOffset = 8; inline constexpr std::uint32_t kSetCmpltStatusOffset = 12; -// ---- REMOTE_NDIS_RESET_MSG(12 字节,**没有 RequestId**)---- +// ---- REMOTE_NDIS_RESET_MSG (12 bytes, **no RequestId**) ---- inline constexpr std::uint32_t kResetMsgBytes = 12; -/// offset 8 是 Reserved,而非 RequestId。因此 RESET 无法用 ID 配对, -/// 同一时刻只能有一个 RESET 在飞。 +/// Offset 8 is Reserved, not RequestId. Therefore RESET cannot be matched by ID and only +/// one RESET may be in flight at a time. inline constexpr std::uint32_t kResetReservedOffset = 8; -// ---- REMOTE_NDIS_RESET_CMPLT(16 字节,**没有 RequestId**)---- +// ---- REMOTE_NDIS_RESET_CMPLT (16 bytes, **no RequestId**) ---- inline constexpr std::uint32_t kResetCmpltBytes = 16; inline constexpr std::uint32_t kResetCmpltStatusOffset = 8; -/// 非零表示寻址信息(packet filter、组播表、functional address)在复位中丢失, -/// host 必须重发相应的 SET。 +/// Non-zero means addressing information (packet filter, multicast list, functional +/// address) was lost in the reset and the host must re-send the corresponding SETs. inline constexpr std::uint32_t kResetCmpltAddressingResetOffset = 12; -// ---- REMOTE_NDIS_INDICATE_STATUS_MSG(头部 20 字节,**没有 RequestId**)---- +// ---- REMOTE_NDIS_INDICATE_STATUS_MSG (20-byte header, **no RequestId**) ---- inline constexpr std::uint32_t kIndicateStatusHeaderBytes = 20; inline constexpr std::uint32_t kIndicateStatusStatusOffset = 8; inline constexpr std::uint32_t kIndicateStatusBufferLengthOffset = 12; -/// 注意基准点争议,见文件头第 3 条规则。 +/// Note the base-point controversy; see header rule 3. inline constexpr std::uint32_t kIndicateStatusBufferOffsetOffset = 16; -/// INDICATE_STATUS 携带的诊断信息结构(RNDIS_Diagnostic_Info,8 字节)。 +/// Diagnostic information structure carried by INDICATE_STATUS (RNDIS_Diagnostic_Info, +/// 8 bytes). inline constexpr std::uint32_t kDiagnosticInfoBytes = 8; inline constexpr std::uint32_t kDiagnosticInfoDiagStatusOffset = 0; inline constexpr std::uint32_t kDiagnosticInfoErrorOffsetOffset = 4; -// ---- REMOTE_NDIS_KEEPALIVE_MSG(12 字节)/ KEEPALIVE_CMPLT(16 字节)---- +// ---- REMOTE_NDIS_KEEPALIVE_MSG (12 bytes) / KEEPALIVE_CMPLT (16 bytes) ---- inline constexpr std::uint32_t kKeepAliveMsgBytes = 12; inline constexpr std::uint32_t kKeepAliveRequestIdOffset = 8; inline constexpr std::uint32_t kKeepAliveCmpltBytes = 16; inline constexpr std::uint32_t kKeepAliveCmpltRequestIdOffset = 8; inline constexpr std::uint32_t kKeepAliveCmpltStatusOffset = 12; -// ---- REMOTE_NDIS_PACKET_MSG(头部 44 字节)---- +// ---- REMOTE_NDIS_PACKET_MSG (44-byte header) ---- inline constexpr std::uint32_t kPacketMsgHeaderBytes = 44; inline constexpr std::uint32_t kPacketDataOffsetOffset = 8; inline constexpr std::uint32_t kPacketDataLengthOffset = 12; @@ -232,38 +246,43 @@ inline constexpr std::uint32_t kPacketPerPacketInfoLengthOffset = 32; inline constexpr std::uint32_t kPacketVcHandleOffset = 36; inline constexpr std::uint32_t kPacketReservedOffset = 40; -/// 「以太帧紧跟 44 字节头部」时 DataOffset 应填的值。**是 36,不是 44。** +/// Value to write for DataOffset when "the Ethernet frame immediately follows the +/// 44-byte header". **It is 36, not 44.** inline constexpr std::uint32_t kPacketInlineDataOffset = kPacketMsgHeaderBytes - kOffsetFieldBase; -static_assert(kPacketInlineDataOffset == 36, "DataOffset 基准点是消息起始 +8,见文件头规则 2"); +static_assert(kPacketInlineDataOffset == 36, + "DataOffset base point is message start + 8; see header rule 2"); -/// device → host 方向,多包聚合时每个 PACKET_MSG 应从 8 字节整数倍偏移开始。 +/// device -> host direction: when aggregating multiple packets, each PACKET_MSG should +/// start at an 8-byte-aligned offset. inline constexpr std::uint32_t kDeviceToHostPacketAlignment = 8; -/// PacketAlignmentFactor 的合法上界(1 << 7 = 128 字节)。 -/// 设备报出更大的值属于协议违规,实现里必须钳位,否则会算出荒谬的填充长度。 +/// Legal upper bound of PacketAlignmentFactor (1 << 7 = 128 bytes). +/// A larger value reported by the device is a protocol violation; implementations must +/// clamp it or absurd padding lengths will be computed. inline constexpr std::uint32_t kMaxPacketAlignmentFactor = 7; // ============================================================================= -// 状态码 +// Status codes // ============================================================================= -/// RNDIS_STATUS_* 状态码。 +/// RNDIS_STATUS_* status codes. /// -/// 刻意命名为 StatusCode 而非 Status:`tetherkit::Status` 已经是 -/// `std::expected` 的别名,在 tetherkit::rndis 里再定义一个 -/// `Status` 会遮蔽它,导致本命名空间内无法书写「可能失败但无返回值」的签名。 +/// Deliberately named StatusCode rather than Status: `tetherkit::Status` is already an +/// alias for `std::expected`, and defining another `Status` in +/// tetherkit::rndis would shadow it, making it impossible to write "may fail with no +/// return value" signatures in this namespace. enum class StatusCode : std::uint32_t { - // ---- 成功 / 挂起 ---- + // ---- Success / pending ---- kSuccess = 0x0000'0000U, kPending = 0x0000'0103U, - // ---- 信息类 ---- + // ---- Informational ---- kNotRecognized = 0x0001'0001U, kNotCopied = 0x0001'0002U, kNotAccepted = 0x0001'0003U, kCallActive = 0x0001'0007U, - // ---- 指示类(0x4001xxxx,出现在 INDICATE_STATUS_MSG 里)---- + // ---- Indications (0x4001xxxx, appear in INDICATE_STATUS_MSG) ---- kOnline = 0x4001'0003U, kResetStart = 0x4001'0004U, kResetEnd = 0x4001'0005U, @@ -272,8 +291,8 @@ enum class StatusCode : std::uint32_t { kWanLineUp = 0x4001'0008U, kWanLineDown = 0x4001'0009U, kWanFragment = 0x4001'000AU, - kMediaConnect = 0x4001'000BU, ///< 链路 up —— 我们据此把 feth 置 UP。 - kMediaDisconnect = 0x4001'000CU, ///< 链路 down。 + kMediaConnect = 0x4001'000BU, ///< Link up -- we bring feth UP on this. + kMediaDisconnect = 0x4001'000CU, ///< Link down. kHardwareLineUp = 0x4001'000DU, kHardwareLineDown = 0x4001'000EU, kInterfaceUp = 0x4001'000FU, @@ -283,13 +302,13 @@ enum class StatusCode : std::uint32_t { kLinkSpeedChange = 0x4001'0013U, kNetworkChange = 0x4001'0018U, - // ---- 警告类(0x8xxxxxxx)---- + // ---- Warnings (0x8xxxxxxx) ---- kBufferOverflow = 0x8000'0005U, kNotResettable = 0x8001'0001U, kSoftErrors = 0x8001'0003U, kHardErrors = 0x8001'0004U, - // ---- 错误类(0xCxxxxxxx)---- + // ---- Errors (0xCxxxxxxx) ---- kFailure = 0xC000'0001U, kResources = 0xC000'009AU, kNotSupported = 0xC000'00BBU, @@ -321,12 +340,12 @@ enum class StatusCode : std::uint32_t { kTokenRingOpenError = 0xC001'1000U, }; -/// 是否为失败状态(0xC0000000 及以上按 NDIS 约定是错误)。 +/// Whether this is a failure status (0xC0000000 and above are errors by NDIS convention). [[nodiscard]] constexpr bool IsFailure(std::uint32_t status) noexcept { return (status & 0xC000'0000U) == 0xC000'0000U; } -/// 状态码的可读名字;未知值返回空。 +/// Human-readable name for a status code; empty for unknown values. [[nodiscard]] std::string_view StatusName(std::uint32_t status) noexcept; // ============================================================================= @@ -334,47 +353,59 @@ enum class StatusCode : std::uint32_t { // ============================================================================= enum class Oid : std::uint32_t { - // ---- 通用(OID_GEN_*)---- - kGenSupportedList = 0x0001'0101U, ///< 变长:N 个 LE32 OID。查询时 in_len 必须为 0。 - kGenHardwareStatus = 0x0001'0102U, ///< LE32:0=Ready 1=Initializing 2=Reset 3=Closing 4=NotReady - kGenMediaSupported = 0x0001'0103U, ///< LE32 数组。 - kGenMediaInUse = 0x0001'0104U, ///< LE32 数组。 - kGenMaximumFrameSize = 0x0001'0106U, ///< LE32:**不含** 14 字节以太头,典型 1500。 - kGenLinkSpeed = 0x0001'0107U, ///< LE32:单位 **100 bps**;断连时为 0。 - kGenTransmitBlockSize = 0x0001'010AU, ///< LE32 - kGenReceiveBlockSize = 0x0001'010BU, ///< LE32 - kGenVendorId = 0x0001'010CU, ///< LE32:低 24 位是 IEEE OUI。 - kGenVendorDescription = 0x0001'010DU, ///< 变长 ASCII,**不保证 NUL 结尾**。 - kGenCurrentPacketFilter = 0x0001'010EU, ///< LE32 位掩码,可读可写。 - kGenMaximumTotalSize = 0x0001'0111U, ///< LE32:含头部的最大总帧长。 - kGenMacOptions = 0x0001'0113U, ///< LE32:MacOption 位掩码。 - kGenMediaConnectStatus = 0x0001'0114U, ///< LE32:**0=已连接,1=断开**(注意 0 是连接)。 - kGenPhysicalMedium = 0x0001'0202U, ///< LE32:PhysicalMedium;**可选 OID**,失败不致命。 - - // ---- 统计(OID_GEN_*_OK/ERROR):可能返回 4 或 8 字节,两者都要接受 ---- + // ---- General (OID_GEN_*) ---- + kGenSupportedList = 0x0001'0101U, ///< Variable length: N LE32 OIDs. in_len must + ///< be 0 on query. + kGenHardwareStatus = 0x0001'0102U, ///< LE32: 0=Ready 1=Initializing 2=Reset + ///< 3=Closing 4=NotReady + kGenMediaSupported = 0x0001'0103U, ///< LE32 array. + kGenMediaInUse = 0x0001'0104U, ///< LE32 array. + kGenMaximumFrameSize = 0x0001'0106U, ///< LE32: **excludes** 14-byte Ethernet header, + ///< typically 1500. + kGenLinkSpeed = 0x0001'0107U, ///< LE32: units of **100 bps**; 0 when + ///< disconnected. + kGenTransmitBlockSize = 0x0001'010AU, ///< LE32 + kGenReceiveBlockSize = 0x0001'010BU, ///< LE32 + kGenVendorId = 0x0001'010CU, ///< LE32: lower 24 bits are the IEEE OUI. + kGenVendorDescription = 0x0001'010DU, ///< Variable-length ASCII, **NUL termination not + ///< guaranteed**. + kGenCurrentPacketFilter = 0x0001'010EU, ///< LE32 bitmask, readable and writable. + kGenMaximumTotalSize = 0x0001'0111U, ///< LE32: maximum total frame length including + ///< header. + kGenMacOptions = 0x0001'0113U, ///< LE32: MacOption bitmask. + kGenMediaConnectStatus = 0x0001'0114U, ///< LE32: **0=connected, 1=disconnected** (note + ///< 0 means connected). + kGenPhysicalMedium = 0x0001'0202U, ///< LE32: PhysicalMedium; **optional OID**, + ///< failure is non-fatal. + + // ---- Statistics (OID_GEN_*_OK/ERROR): may return 4 or 8 bytes; both must be accepted + // ---- kGenXmitOk = 0x0002'0101U, kGenRcvOk = 0x0002'0102U, kGenXmitError = 0x0002'0103U, kGenRcvError = 0x0002'0104U, kGenRcvNoBuffer = 0x0002'0105U, - // ---- 802.3 专用 ---- - kEthernetPermanentAddress = 0x0101'0101U, ///< 6 字节 MAC。 - kEthernetCurrentAddress = 0x0101'0102U, ///< 6 字节 MAC。 - kEthernetMulticastList = 0x0101'0103U, ///< SET:6N 字节 MAC 数组。**不要依赖 QUERY**。 - kEthernetMaximumListSize = 0x0101'0104U, ///< LE32:组播表最大条数。 - kEthernetMacOptions = 0x0101'0105U, ///< LE32:Ethernet802_3MacOption 位掩码。 + // ---- 802.3 specific ---- + kEthernetPermanentAddress = 0x0101'0101U, ///< 6-byte MAC. + kEthernetCurrentAddress = 0x0101'0102U, ///< 6-byte MAC. + kEthernetMulticastList = 0x0101'0103U, ///< SET: 6N-byte MAC array. **Do not rely + ///< on QUERY**. + kEthernetMaximumListSize = 0x0101'0104U, ///< LE32: maximum multicast list entries. + kEthernetMacOptions = 0x0101'0105U, ///< LE32: Ethernet802_3MacOption bitmask. }; -/// OID 的可读名字;未知值返回空。 +/// Human-readable name for an OID; empty for unknown values. [[nodiscard]] std::string_view OidName(std::uint32_t oid) noexcept; -/// 该 OID 的返回值是否为变长。 +/// Whether this OID returns a variable-length value. /// -/// 这个区分很关键:对**定长** OID,QUERY 的 InformationBufferLength 必须设成 -/// ≥ 期望响应长度并在消息尾附上同样多的零字节,否则微软 ActiveSync 实现会回 -/// RNDIS_STATUS_INVALID_LENGTH;而对**变长** OID 必须传 0,否则同样出错。 -/// 这条是嗅探 ActiveSync 4.1 的 Windows 驱动才发现的未文档化行为。 +/// This distinction is critical: for **fixed-length** OIDs, QUERY's +/// InformationBufferLength must be set to >= the expected response length and the same +/// number of zero bytes must be appended at the end of the message, otherwise Microsoft's +/// ActiveSync implementation returns RNDIS_STATUS_INVALID_LENGTH; for **variable-length** +/// OIDs it must be 0, or the same error occurs. This undocumented behavior was discovered +/// by sniffing the ActiveSync 4.1 Windows driver. [[nodiscard]] constexpr bool IsVariableLengthOid(Oid oid) noexcept { switch (oid) { case Oid::kGenSupportedList: @@ -389,16 +420,16 @@ enum class Oid : std::uint32_t { } // ============================================================================= -// 包过滤位掩码(NDIS_PACKET_TYPE_*) +// Packet filter bitmasks (NDIS_PACKET_TYPE_*) // ============================================================================= enum class PacketFilter : std::uint32_t { - kDirected = 0x0000'0001U, ///< 目的 MAC 等于本机地址。 - kMulticast = 0x0000'0002U, ///< 在组播表中的组播地址。 - kAllMulticast = 0x0000'0004U, ///< 全部组播,不看组播表。 - kBroadcast = 0x0000'0008U, ///< ff:ff:ff:ff:ff:ff + kDirected = 0x0000'0001U, ///< Destination MAC equals the local address. + kMulticast = 0x0000'0002U, ///< Multicast address in the multicast list. + kAllMulticast = 0x0000'0004U, ///< All multicast, ignore the multicast list. + kBroadcast = 0x0000'0008U, ///< ff:ff:ff:ff:ff:ff kSourceRouting = 0x0000'0010U, - kPromiscuous = 0x0000'0020U, ///< 全部帧。 + kPromiscuous = 0x0000'0020U, ///< All frames. kSmt = 0x0000'0040U, kAllLocal = 0x0000'0080U, kGroup = 0x0000'1000U, @@ -407,23 +438,27 @@ enum class PacketFilter : std::uint32_t { kMacFrame = 0x0000'8000U, }; -/// 本项目使用的包过滤组合,与 Linux rndis_host 保持一致(0x0000002D)。 +/// Packet filter combination used by this project, kept consistent with Linux rndis_host +/// (0x0000002D). /// -/// 为什么要开 PROMISCUOUS:我们把设备当成一根「网线」桥接到 feth, -/// 主机侧可能收发任意 MAC 的帧(例如上层再做桥接或虚拟机),不能只收 -/// directed。ALL_MULTICAST 同理 —— 避免维护组播表还漏掉 IPv6 邻居发现。 +/// Why PROMISCUOUS is enabled: we bridge the device as a "network cable" to feth, and the +/// host side may send and receive frames with arbitrary MACs (e.g. if upper layers do +/// bridging or run VMs), so we cannot receive only directed frames. ALL_MULTICAST for +/// the same reason -- avoid maintaining a multicast list and still missing IPv6 neighbor +/// discovery. inline constexpr std::uint32_t kDefaultPacketFilter = ToRaw(PacketFilter::kDirected) | ToRaw(PacketFilter::kBroadcast) | ToRaw(PacketFilter::kAllMulticast) | ToRaw(PacketFilter::kPromiscuous); static_assert(kDefaultPacketFilter == 0x0000'002DU); // ============================================================================= -// 介质、物理介质、设备标志 +// Medium, physical medium, device flags // ============================================================================= enum class Medium : std::uint32_t { - kEthernet = 0x0000'0000U, ///< 802.3;同时也是 UNSPECIFIED。本项目只支持这一种。 - kTokenRing = 1, ///< 802.5 + kEthernet = 0x0000'0000U, ///< 802.3; also UNSPECIFIED. This project only supports + ///< this one. + kTokenRing = 1, ///< 802.5 kFddi = 2, kWan = 3, kLocalTalk = 4, @@ -449,20 +484,20 @@ enum class PhysicalMedium : std::uint32_t { kWirelessWan = 8, }; -/// INITIALIZE_CMPLT 的 DeviceFlags 位掩码。 +/// DeviceFlags bitmask of INITIALIZE_CMPLT. enum class DeviceFlags : std::uint32_t { - kConnectionless = 0x0000'0001U, ///< 以太网设备应置此位。 - kConnectionOriented = 0x0000'0002U, ///< CONDIS,本项目不支持。 + kConnectionless = 0x0000'0001U, ///< Ethernet devices should set this bit. + kConnectionOriented = 0x0000'0002U, ///< CONDIS, not supported by this project. kRawData = 0x0000'0004U, }; -/// OID_GEN_MEDIA_CONNECT_STATUS 的取值。**注意 0 表示已连接。** +/// Values of OID_GEN_MEDIA_CONNECT_STATUS. **Note 0 means connected.** enum class MediaState : std::uint32_t { kConnected = 0, kDisconnected = 1, }; -/// OID_GEN_MAC_OPTIONS 位掩码。 +/// OID_GEN_MAC_OPTIONS bitmask. enum class MacOption : std::uint32_t { kCopyLookaheadData = 0x0000'0001U, kReceiveSerialized = 0x0000'0002U, @@ -474,30 +509,30 @@ enum class MacOption : std::uint32_t { }; // ============================================================================= -// USB 层常量(控制通道与描述符识别) +// USB-layer constants (control channel and descriptor identification) // ============================================================================= -/// SEND_ENCAPSULATED_COMMAND 的 bRequest(CDC 规范)。 +/// bRequest for SEND_ENCAPSULATED_COMMAND (CDC specification). inline constexpr std::uint8_t kRequestSendEncapsulatedCommand = 0x00; -/// GET_ENCAPSULATED_RESPONSE 的 bRequest。 +/// bRequest for GET_ENCAPSULATED_RESPONSE. inline constexpr std::uint8_t kRequestGetEncapsulatedResponse = 0x01; -/// bmRequestType:主机 → 设备,类请求,接口收件人。 +/// bmRequestType: host -> device, class request, interface recipient. inline constexpr std::uint8_t kControlOutRequestType = 0x21; -/// bmRequestType:设备 → 主机,类请求,接口收件人。 +/// bmRequestType: device -> host, class request, interface recipient. inline constexpr std::uint8_t kControlInRequestType = 0xA1; -/// 中断 IN 端点上的通知:8 字节 = 两个 LE32。 +/// Notification on the interrupt IN endpoint: 8 bytes = two LE32s. /// -/// 注意这**不是** CDC 的 usb_cdc_notification 结构,而是 RNDIS 自有格式: -/// offset 0..3 = Notification(0x00000001 = RESPONSE_AVAILABLE) -/// offset 4..7 = Reserved(0) +/// Note this is **not** the CDC usb_cdc_notification structure, but RNDIS's own format: +/// offset 0..3 = Notification (0x00000001 = RESPONSE_AVAILABLE) +/// offset 4..7 = Reserved (0) inline constexpr std::uint32_t kNotificationBytes = 8; inline constexpr std::uint32_t kNotificationResponseAvailable = 0x0000'0001U; inline constexpr std::uint32_t kNotificationValueOffset = 0; inline constexpr std::uint32_t kNotificationReservedOffset = 4; -/// USB 接口 class/subclass/protocol 三元组,用于识别 RNDIS 设备。 +/// USB interface class/subclass/protocol triple, used to identify RNDIS devices. struct InterfaceSignature { std::uint8_t interface_class; std::uint8_t interface_subclass; @@ -506,52 +541,55 @@ struct InterfaceSignature { [[nodiscard]] constexpr bool operator==(const InterfaceSignature&) const = default; }; -/// RNDIS 通信类接口的全部已知形态(实测在用的四种)。 +/// All known forms of RNDIS communication-class interface (four seen in practice). /// -/// (a) 标准 MS RNDIS:CDC Communication / ACM / vendor-specific protocol -/// (b) ActiveSync(Windows Mobile 5 / Windows Phone):USB_CLASS_MISC -/// (c) 无线 RNDIS(手机 USB 网络共享、WWAN 模块):Wireless Controller / RF / RNDIS -/// (d) Novatel/Verizon USB730L 变体 +/// (a) Standard MS RNDIS: CDC Communication / ACM / vendor-specific protocol +/// (b) ActiveSync (Windows Mobile 5 / Windows Phone): USB_CLASS_MISC +/// (c) Wireless RNDIS (phone USB tethering, WWAN modules): Wireless Controller / RF / +/// RNDIS +/// (d) Novatel/Verizon USB730L variant inline constexpr InterfaceSignature kControlSignatureMicrosoft{0x02, 0x02, 0xFF}; inline constexpr InterfaceSignature kControlSignatureActiveSync{0xEF, 0x01, 0x01}; inline constexpr InterfaceSignature kControlSignatureWireless{0xE0, 0x01, 0x03}; inline constexpr InterfaceSignature kControlSignatureNovatel{0xEF, 0x04, 0x01}; -/// RNDIS 数据类接口:CDC Data,无子类无协议。 +/// RNDIS data-class interface: CDC Data, no subclass, no protocol. inline constexpr InterfaceSignature kDataSignature{0x0A, 0x00, 0x00}; -/// 是否为已知的 RNDIS 通信类接口签名。 +/// Whether this is a known RNDIS communication-class interface signature. [[nodiscard]] constexpr bool IsRndisControlSignature(const InterfaceSignature& signature) noexcept { return signature == kControlSignatureMicrosoft || signature == kControlSignatureActiveSync || signature == kControlSignatureWireless || signature == kControlSignatureNovatel; } // ============================================================================= -// 帧尺寸推导 +// Frame size derivation // ============================================================================= -/// 以太头长度。 +/// Ethernet header length. inline constexpr std::uint32_t kEthernetHeaderBytes = 14; -/// RNDIS 数据通道的「硬头部」= 以太头 + PACKET_MSG 头。 +/// "Hard header" of the RNDIS data channel = Ethernet header + PACKET_MSG header. /// -/// Linux 的 hard_header_len 就是这个 58,MaxTransferSize 的推导以它为基准。 +/// Linux's hard_header_len is this 58; MaxTransferSize derivation uses it as the base. inline constexpr std::uint32_t kHardHeaderBytes = kEthernetHeaderBytes + kPacketMsgHeaderBytes; static_assert(kHardHeaderBytes == 58); -/// 以太网标准 MTU。 +/// Standard Ethernet MTU. inline constexpr std::uint32_t kDefaultMtu = 1500; -/// 给定 MTU,计算一帧在 USB 上占用的最大字节数(含 RNDIS 头与以太头)。 +/// Given an MTU, compute the maximum bytes a frame occupies on USB (including RNDIS +/// header and Ethernet header). [[nodiscard]] constexpr std::uint32_t HardMtuFor(std::uint32_t mtu) noexcept { return mtu + kHardHeaderBytes; } -/// 计算 INITIALIZE_MSG 里该宣称的 MaxTransferSize。 +/// Compute the MaxTransferSize to declare in INITIALIZE_MSG. /// -/// 沿用 Linux 的算法:`(hard_mtu + maxpacket + 1) & ~(maxpacket - 1)`, -/// 即向上对齐到端点最大包长的整数倍再留一个包的余量。 -/// 高速(maxpacket=512)下 MTU=1500 得 2048;全速(64)下得 1600。 +/// Follows Linux's algorithm: `(hard_mtu + maxpacket + 1) & ~(maxpacket - 1)`, i.e. +/// round up to an integer multiple of the endpoint max packet size and leave one packet +/// of headroom. For MTU=1500 on high-speed (maxpacket=512) this yields 2048; on +/// full-speed (64) it yields 1600. [[nodiscard]] constexpr std::uint32_t MaxTransferSizeFor(std::uint32_t mtu, std::uint32_t endpoint_max_packet) noexcept { const std::uint32_t hard_mtu = HardMtuFor(mtu); @@ -561,7 +599,7 @@ inline constexpr std::uint32_t kDefaultMtu = 1500; static_assert(MaxTransferSizeFor(1500, 512) == 2048); static_assert(MaxTransferSizeFor(1500, 64) == 1600); -/// USB 1.1 设备的 MaxTransferSize 硬上限(规范要求)。 +/// Hard upper limit of MaxTransferSize for USB 1.1 devices (required by specification). inline constexpr std::uint32_t kUsb11MaxTransferSize = 0x4000; } // namespace tetherkit::rndis diff --git a/include/tetherkit/rndis/state_machine.h b/include/tetherkit/rndis/state_machine.h index 9c20b98..8a19ebd 100644 --- a/include/tetherkit/rndis/state_machine.h +++ b/include/tetherkit/rndis/state_machine.h @@ -1,59 +1,66 @@ -// RNDIS 主机侧状态机。 +// RNDIS host-side state machine. // -// ★ 状态图(规范定义的是设备侧三态,主机侧镜像它并补上过渡态)★ +// State diagram (the specification defines three device-side states; the host side +// mirrors them and adds transitional states): // -// ┌──────────────────┐ -// ┌────────────►│ kUninitialized │◄────────────┐ -// │ └────────┬─────────┘ │ -// │ │ Start() │ 收到 HALT 应答 / -// │ │ 发 INITIALIZE_MSG │ 总线断开 / 致命错误 -// │ ▼ │ -// │ ┌──────────────────┐ │ -// │ │ kInitializing ├─────────────┤ -// │ └────────┬─────────┘ 协商失败 │ -// │ │ 收 INITIALIZE_CMPLT │ -// │ │ 且协商通过 │ -// │ ▼ │ -// │ ┌──────────────────┐ │ -// │ │ kInitialized │ │ ← 已可收发控制消息, -// │ └────────┬─────────┘ │ 但数据通道**未开** -// │ │ QUERY MAC/link 等 │ -// │ │ SET packet filter≠0 │ -// │ ▼ │ -// │ ┌──────────────────┐ │ -// │ ┌────┤ kDataInitialized ├─────────────┤ -// │ │ └────────┬─────────┘ │ -// │ │ │ Stop() │ -// │ SET filter=0 │ 发 HALT_MSG │ -// │ (退回上一态) ▼ │ -// │ ┌──────────────────┐ │ -// └─────────────┤ kHalting ├─────────────┘ -// └──────────────────┘ +// +------------------+ +// +----------->| kUninitialized |<------------+ +// | +--------+---------+ | +// | | Start() | HALT reply received / +// | | send INITIALIZE_MSG | bus disconnected / fatal +// | v | error +// | +------------------+ | +// | | kInitializing +-------------+ +// | +--------+---------+ neg failure | +// | | receive | +// | | INITIALIZE_CMPLT | +// | | and negotiation ok | +// | v | +// | +------------------+ | +// | | kInitialized | | <- control messages can be +// | +--------+---------+ | exchanged, but the data +// | | QUERY MAC/link, etc. | channel is **not open** +// | | SET packet filter!=0 | +// | v | +// | +------------------+ | +// | +---+ kDataInitialized +-------------+ +// | | +--------+---------+ | +// | | | Stop() | +// | SET filter=0 | send HALT_MSG | +// | (step back) v | +// | +------------------+ | +// +------------+ kHalting +-------------+ +// +------------------+ // -// 规范的原文语义: -// * 总线初始化后设备处于 RNDIS-uninitialized; -// * 收到 INITIALIZE_MSG 并回 INITIALIZE_CMPLT(SUCCESS) → RNDIS-initialized; -// * 收到 SET(OID_GEN_CURRENT_PACKET_FILTER, **非零**) → RNDIS-data-initialized, -// **数据这才开始流动**; -// * 在 data-initialized 下收到 SET(filter = 0) → 退回 RNDIS-initialized; -// * 任意时刻收到 HALT_MSG 或总线断开 → 立即回 RNDIS-uninitialized。 +// Semantics from the specification: +// * After bus initialization the device is in RNDIS-uninitialized; +// * Receiving INITIALIZE_MSG and replying INITIALIZE_CMPLT(SUCCESS) -> +// RNDIS-initialized; +// * Receiving SET(OID_GEN_CURRENT_PACKET_FILTER, **non-zero**) -> +// RNDIS-data-initialized, **data starts flowing only here**; +// * Receiving SET(filter = 0) while data-initialized -> back to RNDIS-initialized; +// * Receiving HALT_MSG or bus disconnect at any time -> immediately back to +// RNDIS-uninitialized. // -// ★ 三条容易写错的协议细节 ★ +// Three protocol details that are easy to get wrong: // -// 1. **控制请求必须串行化。** GET_ENCAPSULATED_RESPONSE 只能「取下一条排队的 -// 响应」,没有按 RequestId 选取的能力。所以同一时刻只能有一个请求在飞。 +// 1. **Control requests must be serialized.** GET_ENCAPSULATED_RESPONSE can only "take +// the next queued response" and has no ability to select by RequestId. So only one +// request may be in flight at a time. // -// 2. **设备会插队。** 在等某个 *_CMPLT 的过程中,设备完全可能先塞进来一条 -// REMOTE_NDIS_INDICATE_STATUS_MSG(媒体连接状态变化)或一条**设备发起的** -// REMOTE_NDIS_KEEPALIVE_MSG(此时主机必须回 KEEPALIVE_CMPLT,否则设备可能 -// 认为主机已死而断开)。因此控制读循环必须写成 dispatcher:按 MessageType -// 分派,不是「盲目假定读到的就是我要的那条」。 +// 2. **The device can jump the queue.** While waiting for some *_CMPLT, the device may +// well insert a REMOTE_NDIS_INDICATE_STATUS_MSG (media connection state change) or +// a **device-initiated** REMOTE_NDIS_KEEPALIVE_MSG first (in which case the host +// must reply with KEEPALIVE_CMPLT, otherwise the device may assume the host is dead +// and disconnect). Therefore the control read loop must be written as a dispatcher: +// dispatch by MessageType, not "blindly assume the one read is the one I want". // -// 3. **RESET 没有 RequestId。** RESET_MSG 的 offset 8 是 Reserved, -// RESET_CMPLT 的 offset 8 是 Status(不是 12)。所以 RESET 无法用 ID 配对, -// 同一时刻只能有一个在飞。而且 RESET_CMPLT 里的 AddressingReset 非零时, -// 主机**必须重发** SET(OID_GEN_CURRENT_PACKET_FILTER)(若设过组播还要重发 -// SET(OID_802_3_MULTICAST_LIST))—— 否则复位后数据不会再流动。 +// 3. **RESET has no RequestId.** RESET_MSG's offset 8 is Reserved, and RESET_CMPLT's +// offset 8 is Status (not 12). So RESET cannot be matched by ID and only one may be +// in flight at a time. Moreover, when AddressingReset in RESET_CMPLT is non-zero, +// the host **must re-send** SET(OID_GEN_CURRENT_PACKET_FILTER) (and +// SET(OID_802_3_MULTICAST_LIST) if multicast was configured) -- otherwise data will +// not flow again after reset. #pragma once #include @@ -69,102 +76,116 @@ namespace tetherkit::rndis { -/// 主机侧状态。 +/// Host-side state. enum class State : std::uint8_t { - kUninitialized, ///< 未初始化。控制通道可用,但设备还没同意开工。 - kInitializing, ///< 已发 INITIALIZE_MSG,等 INITIALIZE_CMPLT。 - kInitialized, ///< 协商完成。可读写 OID,但**数据通道还没开**。 - kDataInitialized, ///< 已设非零包过滤,数据开始流动。 - kHalting, ///< 已发 HALT_MSG,正在收尾。 + kUninitialized, ///< Uninitialized. Control channel is usable, but the device has + ///< not agreed to start. + kInitializing, ///< INITIALIZE_MSG sent, waiting for INITIALIZE_CMPLT. + kInitialized, ///< Negotiation complete. OIDs can be read/written, but the + ///< **data channel is not open yet**. + kDataInitialized, ///< Non-zero packet filter set, data is flowing. + kHalting, ///< HALT_MSG sent, shutting down. }; [[nodiscard]] std::string_view StateName(State state) noexcept; -/// 设备汇报的、主机关心的设备信息。 +/// Device information reported by the device that the host cares about. struct DeviceInfo { - /// 设备的永久 MAC。这是主机侧 feth 应该采用的地址 —— RNDIS 语义下设备就是 - /// 那块网卡,对端的 ARP 表与 DHCP 租约都按它建立。 + /// Device's permanent MAC. This is the address the host-side feth should adopt -- + /// under RNDIS semantics the device is that network interface, and the peer's ARP + /// table and DHCP leases are built around it. MacAddress permanent_address{}; - /// 当前 MAC。多数设备与 permanent 相同。 + /// Current MAC. For most devices this is the same as permanent. MacAddress current_address{}; bool has_permanent_address = false; bool has_current_address = false; - /// 链路速率,**单位 100 bps**(这是 NDIS 的约定,不是 bps)。0 表示断连。 + /// Link speed, in **units of 100 bps** (this is the NDIS convention, not bps). 0 + /// means disconnected. std::uint32_t link_speed_100bps = 0; - /// 最大帧长,**不含** 14 字节以太头。典型 1500。 + /// Maximum frame size, **excluding** the 14-byte Ethernet header. Typically 1500. std::uint32_t maximum_frame_size = 0; - /// 物理介质。可选 OID,查不到时保持 kUnspecified。 + /// Physical medium. Optional OID; remains kUnspecified when not available. PhysicalMedium physical_medium = PhysicalMedium::kUnspecified; - /// 媒体连接状态。**注意 0 表示已连接。** + /// Media connection state. **Note 0 means connected.** MediaState media_state = MediaState::kConnected; - /// 厂商描述串(不保证 NUL 结尾,这里已规整成 std::string)。 + /// Vendor description string (NUL termination not guaranteed; normalized to + /// std::string here). std::string vendor_description; std::uint32_t vendor_id = 0; - /// 链路速率换算成 Mbps,仅用于日志。 + /// Link speed converted to Mbps, for logging only. [[nodiscard]] double LinkSpeedMbps() const noexcept { return static_cast(link_speed_100bps) * 100.0 / 1e6; } }; -/// 状态机的配置。 +/// State machine configuration. struct StateMachineConfig { - /// 希望使用的 MTU。设备装不下时会被 Negotiate 下调。 + /// MTU to request. Negotiated down by Negotiate when the device cannot accommodate it. std::uint32_t requested_mtu = kDefaultMtu; - /// 主机在 INITIALIZE_MSG 里宣称的 MaxTransferSize,也是我们 bulk IN 缓冲的大小。 + /// MaxTransferSize the host declares in INITIALIZE_MSG, also the size of our bulk IN + /// buffer. /// - /// **这是 RNDIS 吞吐最重要的杠杆**:它是让设备把多个 REMOTE_NDIS_PACKET_MSG - /// 聚合进一次 bulk IN 的**唯一**手段。报得越大、设备聚合越多、每帧摊到的 USB - /// 与系统调用开销越低。Linux 保守地只报 2048(一个满帧),我们报 16 KiB —— - /// 既留足聚合空间,又不超过规范对 USB 1.1 设备的 0x4000 上限。 + /// **This is the most important lever for RNDIS throughput**: it is the **only** way + /// to get the device to aggregate multiple REMOTE_NDIS_PACKET_MSGs into one bulk IN + /// transfer. The larger it is, the more the device aggregates and the lower the USB and + /// syscall overhead amortized per frame. Linux conservatively reports only 2048 (one + /// full frame); we report 16 KiB -- enough aggregation headroom without exceeding the + /// specification's 0x4000 limit for USB 1.1 devices. std::uint32_t host_max_transfer_size = 16 * 1024; - /// host → device 方向每次 bulk OUT 最多聚合几个 PACKET_MSG。0 表示不额外限制, - /// 完全听设备汇报的 MaxPacketsPerMessage。 + /// Maximum PACKET_MSGs to aggregate per bulk OUT in the host -> device direction. 0 + /// means no extra limit, fully follow the device's reported MaxPacketsPerMessage. /// - /// 之所以留这个旋钮:设备汇报的聚合能力**未必可信**。主线 Linux gadget 的 - /// `rndis_rm_hdr()` 每次 bulk OUT 只拆一个 PACKET_MSG,而厂商内核照样可能汇报 - /// MaxPacketsPerMessage=10。若真遇上这种设备,除第一个以外的帧会被对端悄悄丢掉, - /// 钳到 1 即可退回「一帧一传输」。 + /// Why this knob exists: the device's reported aggregation capability is **not + /// necessarily trustworthy**. The mainline Linux gadget's `rndis_rm_hdr()` unpacks only + /// one PACKET_MSG per bulk OUT, yet vendor kernels may still report + /// MaxPacketsPerMessage=10. On such a device, every frame after the first would be + /// silently dropped by the peer; clamping to 1 falls back to "one frame per transfer". /// - /// ⚠️ **但别把它当成丢帧的默认解释。** 在实测过的 Android 设备上 - /// (汇报 10 包 / 15800 字节)做过 A/B:钳到 1 反而更慢(TCP TX 125 vs 234 Mbps), - /// 丢帧率也没改善 —— 那台设备的聚合是**真的有效**的。所以这是一个诊断/兜底 - /// 旋钮,不是已知缺陷的开关。详见 docs/BENCHMARKS.md「真机端到端实测」。 + /// **But do not treat it as the default explanation for frame drops.** On a measured + /// Android device (reporting 10 packets / 15800 bytes) we ran an A/B: clamping to 1 + /// was actually slower (TCP TX 125 vs 234 Mbps) and did not improve the drop rate -- + /// that device's aggregation was **genuinely effective**. So this is a diagnostic / + /// backstop knob, not a switch for a known defect. See docs/BENCHMARKS.md "real-device + /// end-to-end measurements". std::uint32_t max_tx_packets_per_message = 0; - /// 要设置的包过滤位掩码。 + /// Packet filter bitmask to set. std::uint32_t packet_filter = kDefaultPacketFilter; - /// 控制传输超时(毫秒)。 + /// Control transfer timeout (milliseconds). std::uint32_t control_timeout_millis = kControlTimeoutMillis; - /// 保活周期(毫秒)。 + /// Keep-alive period (milliseconds). /// - /// 语义是「距上次从设备收到任何消息已过这么久」才发,而不是无条件定时发。 - /// 注意 Apple Silicon 上 libusb 的控制传输比 x64 慢约 10 倍(issue #1288), - /// 所以别把这个值调得太小。 + /// Semantics are "send only after this long has passed since the last message received + /// from the device", not unconditionally on a fixed schedule. Note that on Apple + /// Silicon libusb control transfers are about 10x slower than on x64 (issue #1288), so + /// do not set this value too small. std::uint32_t keepalive_interval_millis = kKeepAliveTimeoutMillis; - /// 单次控制请求的响应轮询次数上限。 + /// Maximum number of response poll attempts per control request. /// - /// Linux 的做法是最多轮询 10 次、每次间隔 40 ms(总约 400 ms)。我们先等中断 - /// 端点的通知,超时后再轮询,因此这里主要是给「没有中断端点」的设备兜底。 + /// Linux polls up to 10 times at 40 ms intervals (about 400 ms total). We wait for the + /// interrupt endpoint notification first and poll after timeout, so this is mainly a + /// backstop for devices with no interrupt endpoint. std::uint32_t response_poll_attempts = 10; - /// 每次轮询之间的等待(毫秒)。 + /// Wait between each poll (milliseconds). std::uint32_t response_poll_interval_millis = 40; - /// 连续多少次保活失败后判定链路已死。 + /// How many consecutive keep-alive failures before the link is considered dead. std::uint32_t keepalive_failure_threshold = 3; }; -/// 状态机对外通报的事件。 +/// Events reported by the state machine to the outside. /// -/// 用回调而不是让调用方轮询状态,是因为「媒体连接状态变化」这类事件由设备主动 -/// 推送(INDICATE_STATUS),轮询会漏掉边沿。 +/// Callbacks are used instead of having the caller poll state because events like "media +/// connection state change" are pushed proactively by the device (INDICATE_STATUS); +/// polling would miss edges. class StateMachineObserver { public: StateMachineObserver() = default; @@ -174,28 +195,31 @@ class StateMachineObserver { StateMachineObserver& operator=(StateMachineObserver&&) = delete; virtual ~StateMachineObserver() = default; - /// 状态迁移。 + /// State transition. virtual void OnStateChanged(State from, State to) = 0; - /// 协商完成,数据路径可以按这些参数搭建了。 + /// Negotiation complete; the data path can be built with these parameters. virtual void OnNegotiated(const NegotiatedParameters& parameters, const DeviceInfo& info) = 0; - /// 链路 up / down(来自 INDICATE_STATUS 的 MEDIA_CONNECT / MEDIA_DISCONNECT, - /// 或 QUERY OID_GEN_MEDIA_CONNECT_STATUS 的结果)。 + /// Link up / down (from INDICATE_STATUS MEDIA_CONNECT / MEDIA_DISCONNECT, or from the + /// result of QUERY OID_GEN_MEDIA_CONNECT_STATUS). virtual void OnLinkStateChanged(bool connected) = 0; - /// 设备复位完成,且要求主机重发寻址配置。数据路径需要短暂暂停。 + /// Device reset complete and the host is required to re-send addressing + /// configuration. The data path needs a brief pause. virtual void OnDeviceReset(bool addressing_lost) = 0; - /// 链路已不可用(保活连续失败 / 设备断开 / 不可恢复的协议错误)。 + /// Link is no longer usable (consecutive keep-alive failures / device disconnect / + /// unrecoverable protocol error). virtual void OnFatalError(const Error& error) = 0; }; -/// RNDIS 主机侧状态机。 +/// RNDIS host-side state machine. /// -/// **线程约束:本类的所有方法必须从同一个线程调用**(控制线程)。这不是实现 -/// 偷懒,而是 libusb 的硬性要求 —— 同步控制传输在事件线程上会返回 -/// LIBUSB_ERROR_BUSY,所以控制通道必须独占一个非事件线程。 +/// **Threading constraint: all methods of this class must be called from the same +/// thread** (the control thread). This is not an implementation shortcut but a hard +/// libusb requirement -- synchronous control transfers return LIBUSB_ERROR_BUSY on the +/// event thread, so the control channel must own a non-event thread exclusively. class StateMachine { public: StateMachine(ControlChannel& channel, StateMachineObserver& observer, @@ -213,93 +237,104 @@ class StateMachine { [[nodiscard]] const NegotiatedParameters& Parameters() const noexcept { return parameters_; } - /// 走完完整的启动序列,直到 kDataInitialized。 + /// Run the full startup sequence until kDataInitialized. /// - /// 序列(顺序照 Linux 的 generic_rndis_bind,逐步都有依据): - /// 1. INITIALIZE_MSG → INITIALIZE_CMPLT,协商 MaxTransferSize / 对齐 / MTU; - /// 2. QUERY OID_GEN_PHYSICAL_MEDIUM(**可选 OID,失败不致命**); - /// 3. QUERY OID_802_3_PERMANENT_ADDRESS(拿主机侧要用的 MAC); + /// Sequence (following Linux's generic_rndis_bind order, each step grounded): + /// 1. INITIALIZE_MSG -> INITIALIZE_CMPLT, negotiate MaxTransferSize / alignment / MTU; + /// 2. QUERY OID_GEN_PHYSICAL_MEDIUM (**optional OID, failure is non-fatal**); + /// 3. QUERY OID_802_3_PERMANENT_ADDRESS (get the MAC for the host side); /// 4. QUERY OID_802_3_CURRENT_ADDRESS / OID_GEN_MAXIMUM_FRAME_SIZE / - /// OID_GEN_LINK_SPEED / OID_GEN_MEDIA_CONNECT_STATUS(均非致命); - /// 5. SET OID_GEN_CURRENT_PACKET_FILTER = 非零 → 数据开始流动。 - /// 任一致命步骤失败都会发 HALT_MSG 再返回错误。 + /// OID_GEN_LINK_SPEED / OID_GEN_MEDIA_CONNECT_STATUS (all non-fatal); + /// 5. SET OID_GEN_CURRENT_PACKET_FILTER = non-zero -> data starts flowing. + /// Any fatal step failure sends HALT_MSG before returning an error. [[nodiscard]] Status Start(); - /// 优雅停机:SET filter = 0,再发 HALT_MSG。 + /// Graceful shutdown: SET filter = 0, then send HALT_MSG. /// - /// 即使中途出错也会尽最大努力把 HALT 发出去 —— 不发的话设备可能一直以为主机 - /// 还在,下次插上时状态不干净。 + /// Even if errors occur midway, best effort is made to send HALT -- without it the + /// device may keep thinking the host is still there and leave its state dirty on next + /// plug-in. void Stop(); - /// 处理一次「该做的周期性工作」。应由控制线程在循环里定期调用。 + /// Perform one round of "periodic work that needs doing". Should be called regularly + /// by the control thread in its loop. /// - /// 做两件事: - /// * 距上次从设备收到消息超过保活周期时,发一次 KEEPALIVE; - /// * 排空控制通道上设备主动推送的消息(INDICATE_STATUS / 设备发起的 - /// KEEPALIVE),不要让它们堆积。 + /// Does two things: + /// * Sends a KEEPALIVE when more than the keep-alive period has passed since the last + /// message was received from the device; + /// * Drains messages proactively pushed by the device on the control channel + /// (INDICATE_STATUS / device-initiated KEEPALIVE) so they do not pile up. /// - /// 返回错误表示链路已不可用,调用方应重连。 + /// Returning an error means the link is no longer usable and the caller should + /// reconnect. [[nodiscard]] Status Poll(); - /// 距下次该做周期性工作还剩多少毫秒。供调用方决定睡多久。 + /// Milliseconds until the next round of periodic work is due. For the caller to decide + /// how long to sleep. [[nodiscard]] std::uint32_t MillisUntilNextPoll() const noexcept; - /// 主动请求设备软复位。 + /// Proactively request a device soft reset. /// - /// RESET 是 soft reset:控制通道保持完整,但设备会丢弃所有未完成的请求与数据包。 - /// 若 RESET_CMPLT 的 AddressingReset 非零,本方法会自动重发包过滤设置。 + /// RESET is a soft reset: the control channel stays intact, but the device discards all + /// outstanding requests and packets. If AddressingReset in RESET_CMPLT is non-zero, + /// this method automatically re-sends the packet filter setting. [[nodiscard]] Status Reset(); private: - /// 一次控制请求-响应往返的结果。 + /// Result of one control request-response round trip. struct Exchange { - /// 响应消息的字节视图,指向控制通道内部缓冲。 + /// Byte view of the response message, pointing into the control channel's internal + /// buffer. std::span response; }; - /// 发一条请求,然后等它的响应。 + /// Send a request and wait for its response. /// - /// 期间会正确处理设备的插队消息(INDICATE_STATUS / 设备发起的 KEEPALIVE): - /// 分派掉它们并继续等我们要的那条。 + /// Correctly handles messages that jump the queue during the wait (INDICATE_STATUS / + /// device-initiated KEEPALIVE): dispatches them and keeps waiting for the one we want. [[nodiscard]] Result Transact(std::span request, MessageType expected_reply); - /// 读取并分派一条设备推送的消息。 + /// Read and dispatch one message pushed by the device. /// - /// @param expected_reply 我们正在等的消息类型;读到它就返回 true 并把视图 - /// 写进 out_response。传 std::nullopt 表示「只排空推送 - /// 消息,不等任何特定回复」。 + /// @param expected_reply The message type we are waiting for; if read, returns true + /// and writes the view to out_response. Pass std::nullopt to + /// "drain only pushed messages without waiting for any specific + /// reply". [[nodiscard]] Result PumpOnce(std::optional expected_reply, std::span& out_response); - /// 处理一条 INDICATE_STATUS。 + /// Handle an INDICATE_STATUS message. void HandleIndicateStatus(std::span message); - /// 处理设备主动发来的 KEEPALIVE_MSG —— **必须回 KEEPALIVE_CMPLT**。 + /// Handle a KEEPALIVE_MSG initiated by the device -- **must reply with + /// KEEPALIVE_CMPLT**. [[nodiscard]] Status HandleDeviceKeepAlive(std::span message); - /// 查一个 OID。`fatal` 为 false 时,设备返回不支持不算错误。 + /// Query an OID. When `fatal` is false, the device returning "not supported" is not an + /// error. [[nodiscard]] Result> QueryOid(Oid oid, std::uint32_t expected_bytes, bool fatal); - /// 写一个 LE32 型 OID。 + /// Write an LE32-valued OID. [[nodiscard]] Status SetOidUint32(Oid oid, std::uint32_t value); - /// 采集设备信息(第 2~4 步)。非致命 OID 的失败只记日志。 + /// Collect device information (steps 2-4). Failures of non-fatal OIDs are only logged. [[nodiscard]] Status CollectDeviceInfo(); - /// 发 HALT_MSG。尽最大努力,不返回错误。 + /// Send HALT_MSG. Best effort, does not return an error. void SendHalt() noexcept; void TransitionTo(State next); - /// 分配下一个 RequestId。 + /// Allocate the next RequestId. /// - /// 从 1 开始递增并跳过 0:某些设备把 0 当作「无效 ID」。 + /// Increments from 1 and skips 0: some devices treat 0 as an "invalid ID". [[nodiscard]] std::uint32_t NextRequestId() noexcept; - /// 记录「刚从设备收到了消息」,用于保活的空闲判定。 + /// Record "just received a message from the device", used for keep-alive idle + /// detection. void MarkDeviceActivity() noexcept { last_device_activity_ = MonotonicNanos(); } ControlChannel* channel_; @@ -315,10 +350,11 @@ class StateMachine { Nanos next_keepalive_deadline_ = 0; std::uint32_t consecutive_keepalive_failures_ = 0; - /// 上一次成功设置的包过滤值。RESET 后要求重放时用它。 + /// Packet filter value last set successfully. Used to replay it when RESET requires it. std::uint32_t active_packet_filter_ = 0; - /// 请求消息的组装缓冲。控制消息很小,一个缓冲足够。 + /// Assembly buffer for request messages. Control messages are small; one buffer is + /// enough. std::vector request_buffer_; bool link_connected_ = false; diff --git a/include/tetherkit/usb/context.h b/include/tetherkit/usb/context.h index e9f6bb0..b3920dd 100644 --- a/include/tetherkit/usb/context.h +++ b/include/tetherkit/usb/context.h @@ -1,34 +1,43 @@ -// libusb 上下文与事件循环线程。 +// libusb context and event loop thread. // -// ★ macOS 上 libusb 的线程模型(实测 + 源码确认,与直觉不同,务必读懂)★ +// libusb's threading model on macOS (measured + confirmed in the source; +// contrary to intuition, this must be understood) // -// libusb 的 darwin 后端自己起了一个内部线程(org.libusb.device-hotplug)跑 -// CFRunLoop,IOKit 的完成通知在那个线程上到达。但**用户的 transfer 回调不在 -// 那个线程执行** —— darwin_async_io_callback 只是把 transfer 挂进 -// ctx->completed_transfers 并写 event pipe,真正调用回调的是**任何调用 -// libusb_handle_events*() 的线程**。 +// libusb's darwin backend starts an internal thread of its own +// (org.libusb.device-hotplug) running a CFRunLoop, and IOKit's completion +// notifications arrive on that thread. But **the user's transfer callback +// does not run on that thread** -- darwin_async_io_callback merely puts the +// transfer into ctx->completed_transfers and writes the event pipe; the +// thread that actually invokes the callback is **whichever thread calls +// libusb_handle_events*()**. // -// 由此推出三条铁律: +// Three ironclad rules follow: // -// 1. 必须有人持续调用 libusb_handle_events*(),否则回调永远不会被调用。 -// 本类就是干这个的:一个专用线程死循环 handle_events。 +// 1. Somebody must keep calling libusb_handle_events*(), otherwise the +// callback is never invoked. That is what this class is for: one +// dedicated thread looping on handle_events. // -// 2. **回调里绝对不能调用同步 API**(libusb_control_transfer / -// libusb_bulk_transfer)。它们开头就是 -// `if (usbi_handling_events(ctx)) return LIBUSB_ERROR_BUSY;`, -// 这是个 TLS 判断 —— 从事件线程(含任何回调内部)调用必然失败。 -// → RNDIS 控制通道因此必须跑在**独立线程**上,见 rndis/state_machine。 +// 2. **Never call the synchronous API from inside a callback** +// (libusb_control_transfer / libusb_bulk_transfer). They open with +// `if (usbi_handling_events(ctx)) return LIBUSB_ERROR_BUSY;`, +// a TLS check -- calling them from the event thread (including from +// inside any callback) always fails. +// -> The RNDIS control channel must therefore run on a **separate +// thread**, see rndis/state_machine. // -// 3. **回调里绝对不能做阻塞 I/O**。usbi_handle_transfer_completion 调用回调时 -// 持有 ctx->event_waiters_lock;另一个线程正在等同步传输完成时要抢这把锁, -// 回调阻塞多久就把它拖多久。 -// → RX 回调只做「解 RNDIS 包 + memcpy 进无锁队列 + 立刻 resubmit」, -// 真正的 BPF write() 交给另一个线程。 +// 3. **Never do blocking I/O inside a callback.** usbi_handle_transfer_completion +// holds ctx->event_waiters_lock while it invokes the callback; another +// thread waiting for a synchronous transfer to complete needs that very +// lock, so a callback that blocks holds it up for exactly as long. +// -> The RX callback only does "decode the RNDIS packet + memcpy into the +// lock-free queue + resubmit immediately"; the real BPF write() is +// handed to another thread. // -// 4. 回调里**直接 resubmit 同一个 transfer 是官方支持的** -// (usbi_handle_transfer_completion 在调回调前已经把它移出 flying list -// 并清了 IN_FLIGHT 标志,且不持 itransfer->lock)。这是维持 USB 管道满载 -// 的关键手法。 +// 4. **Resubmitting the same transfer directly from inside the callback is +// officially supported** (usbi_handle_transfer_completion has already +// removed it from the flying list and cleared the IN_FLIGHT flag before +// invoking the callback, and does not hold itransfer->lock). This is the +// key trick for keeping a USB pipe saturated. #pragma once #include @@ -42,13 +51,14 @@ namespace tetherkit::usb { -/// libusb 上下文 + 专用事件循环线程。 +/// A libusb context plus a dedicated event loop thread. /// -/// 生命周期约束:所有设备句柄与 transfer 都必须在本对象析构**之前**释放完毕。 -/// 析构顺序 = 停事件线程 → libusb_exit。 +/// Lifetime constraint: every device handle and transfer must be released +/// **before** this object is destroyed. Destructor order = stop the event +/// thread -> libusb_exit. class Context { public: - /// 初始化 libusb 并启动事件线程。 + /// Initializes libusb and starts the event thread. [[nodiscard]] static Result> Create(); Context(const Context&) = delete; @@ -60,16 +70,16 @@ class Context { [[nodiscard]] ::libusb_context* Raw() const noexcept { return context_; } - /// libusb 版本串,用于日志。 + /// The libusb version string, for logging. [[nodiscard]] static std::string VersionString(); - /// 是否支持热插拔(darwin 上恒为 true)。 + /// Whether hotplug is supported (always true on darwin). [[nodiscard]] static bool SupportsHotplug() noexcept; - /// 请求事件线程退出。可从任意线程调用,幂等。 + /// Asks the event thread to exit. Callable from any thread, idempotent. void RequestStop() noexcept; - /// 事件线程是否仍在运行。 + /// Whether the event thread is still running. [[nodiscard]] bool Running() const noexcept { return running_.load(std::memory_order_acquire); } @@ -77,7 +87,7 @@ class Context { private: Context() = default; - /// 事件线程主体。 + /// The event thread body. void RunEventLoop() noexcept; ::libusb_context* context_ = nullptr; diff --git a/include/tetherkit/usb/data_channel.h b/include/tetherkit/usb/data_channel.h index d89f9be..9bb3e27 100644 --- a/include/tetherkit/usb/data_channel.h +++ b/include/tetherkit/usb/data_channel.h @@ -1,41 +1,52 @@ -// RNDIS 数据通道:异步 bulk IN / OUT 传输池。 +// RNDIS data channel: a pool of asynchronous bulk IN / OUT transfers. // -// ★ 无 use-after-free 的拆除算法(本文件最重要的部分)★ +// The teardown algorithm that avoids use-after-free (the most important +// part of this file) // -// libusb_close **不会**帮你回收在飞 transfer。do_close() 对仍在飞的 transfer -// 只是 list_del + 把 transfer->dev_handle 置空,再打一条 -// "Device handle closed while transfer was still being processed" 的日志 —— -// **不调用你的回调、不释放内存**。而之后 darwin 后端 close 会 -// USBInterfaceClose 触发 IOKit 中止,darwin_async_io_callback 仍然会在 libusb -// 内部线程上跑并调 usbi_signal_transfer_completion()。如果那时你已经 -// libusb_free_transfer 了,就是 use-after-free。 +// libusb_close does **not** reap in-flight transfers for you. do_close() +// only does list_del + nulling transfer->dev_handle for the transfers still +// in flight, and then logs "Device handle closed while transfer was still +// being processed" -- it **does not invoke your callback and does not free +// the memory**. The later close of the darwin backend calls +// USBInterfaceClose, which triggers the IOKit abort, and +// darwin_async_io_callback still runs on an internal libusb thread and calls +// usbi_signal_transfer_completion(). If you have already called +// libusb_free_transfer by then, that is a use-after-free. // -// 正确顺序(Shutdown() 实现的就是这个): -// 1. 置停机标志 —— 回调看到它就不再 resubmit,让在飞数自然收敛; -// 2. 对在飞 transfer 调 libusb_cancel_transfer; -// 3. **等到在飞计数归零**(每个回调都回来了); -// 4. 这时才 libusb_free_transfer + 释放缓冲; -// 5. 调用方之后才可以销毁 Device(release_interface → close)。 +// The correct order (this is what Shutdown() implements): +// 1. Set the shutdown flag -- the callback sees it and stops resubmitting, +// so the in-flight count converges on its own; +// 2. Call libusb_cancel_transfer on the in-flight transfers; +// 3. **Wait for the in-flight count to reach zero** (every callback has +// come back); +// 4. Only then libusb_free_transfer and release the buffers; +// 5. Only after that may the caller destroy the Device (release_interface +// -> close). // -// ⚠️ 第 3 步**绝对不能在 libusb 事件线程上等** —— 回调正是在那个线程上跑的, -// 在那里等就是自己等自己,必然死锁。Shutdown() 因此只能从控制线程或主线程 -// 调用,代码里用注释和断言双重提醒。 +// Step 3 **must not be waited for on the libusb event thread** -- the +// callback runs on exactly that thread, so waiting there is waiting for +// yourself, a guaranteed deadlock. Shutdown() can therefore only be +// called from the control thread or the main thread; the code reminds +// of that with both a comment and an assertion. // -// 关于 darwin 的 cancel 粒度:libusb_cancel_transfer 在 darwin 上是 -// darwin_abort_transfers → **AbortPipe(pipeRef)**,即取消一个 transfer 会 -// 取消**该端点上所有**在飞 transfer(全部以 kIOReturnAborted → -// LIBUSB_TRANSFER_CANCELLED 回来)。所以其实只需对每个端点调一次, -// 但逐个调是幂等且更清晰的,不做这个微优化。 +// On the granularity of darwin's cancel: libusb_cancel_transfer on darwin +// is darwin_abort_transfers -> **AbortPipe(pipeRef)**, that is, cancelling +// one transfer cancels **all** in-flight transfers **on that endpoint** +// (they all come back as kIOReturnAborted -> LIBUSB_TRANSFER_CANCELLED). +// So really one call per endpoint would suffice, but calling them one by +// one is idempotent and clearer; this micro-optimization is not made. // -// ★ 为什么「宁少而大,勿多而小」★ +// Why "fewer but larger" beats "many but small" // -// darwin 后端在**每次** submit_bulk_transfer 之前都调 darwin_get_pipe_properties, -// 在 IOUSBInterfaceInterface >= 550 上等于两次 IOKit user-client 调用 -// (GetPipePropertiesV3 + GetEndpointPropertiesV3)。也就是说 macOS 上每次 -// libusb_submit_transfer 至少 3 次 IOKit 往返,而不是 1 次。 -// 反过来,darwin 后端对单次 bulk 传输**没有任何长度上限、不做分片** -// (Linux 后端才有 MAX_BULK_BUFFER_LENGTH = 16384 的 URB 拆分)。 -// → 结论:用较少、较大的传输。 +// The darwin backend calls darwin_get_pipe_properties before **every** +// submit_bulk_transfer, which on IOUSBInterfaceInterface >= 550 amounts to +// two IOKit user-client calls (GetPipePropertiesV3 + GetEndpointPropertiesV3). +// In other words, every libusb_submit_transfer on macOS costs at least 3 +// IOKit round trips, not 1. +// Conversely, the darwin backend has **no length limit and does no +// fragmentation** for a single bulk transfer (only the Linux backend has the +// MAX_BULK_BUFFER_LENGTH = 16384 URB splitting). +// -> Conclusion: use fewer, larger transfers. #pragma once #include @@ -60,63 +71,80 @@ namespace tetherkit::usb { -/// 数据通道的配置。 +/// Data channel configuration. struct DataChannelConfig { - /// 并发在飞的 bulk IN 传输数。 + /// How many bulk IN transfers are in flight concurrently. /// - /// 8 个 × 16 KiB = 128 KiB 在飞。调研给的经验值是「在飞总量 ≥ 256 KiB」才能 - /// 在 USB 2.0 高速(53.2 MB/s 上限)下保证控制器队列不见底 —— 按 50 MB/s 计, - /// 1 ms 的空档就浪费 50 KB。所以默认 16 个 × 16 KiB = 256 KiB。 + /// 8 x 16 KiB = 128 KiB in flight. The value from the research is that "a + /// total in flight of >= 256 KiB" is needed to keep the controller queue + /// from running dry at USB 2.0 high speed (a 53.2 MB/s ceiling) -- at + /// 50 MB/s a 1 ms gap wastes 50 KB. So the default is 16 x 16 KiB = + /// 256 KiB. std::uint32_t rx_transfer_count = 16; - /// 每个 bulk IN 缓冲的字节数。 + /// The byte size of each bulk IN buffer. /// - /// 必须 >= 我们在 INITIALIZE_MSG 里宣称的 MaxTransferSize,否则设备聚合出来的 - /// 大传输会溢出/被截断。运行时会用协商结果覆盖这个值。 + /// It must be >= the MaxTransferSize we advertise in INITIALIZE_MSG, + /// otherwise the large transfers the device aggregates overflow or get + /// truncated. The runtime overrides this value with the negotiated result. std::uint32_t rx_transfer_bytes = 16 * 1024; - /// 并发在飞的 bulk OUT 传输数。 + /// How many bulk OUT transfers are in flight concurrently. /// - /// TX 侧不需要像 RX 那么多:帧是我们主动攒批发出去的,4 个足以让 - /// 「组装下一批」与「上一批在飞」重叠起来。 + /// The TX side does not need as many as RX: the frames are accumulated and + /// sent in batches by us, and 4 are enough to overlap "assemble the next + /// batch" with "the previous batch is in flight". std::uint32_t tx_transfer_count = 4; - /// 每个 bulk OUT 缓冲的字节数。运行时会被协商出的设备 MaxTransferSize 覆盖。 + /// The byte size of each bulk OUT buffer. The runtime overrides it with the + /// negotiated device MaxTransferSize. std::uint32_t tx_transfer_bytes = 16 * 1024; - /// bulk IN 的超时(毫秒)。 + /// The bulk IN timeout (milliseconds). /// - /// **必须是 0(无限)**:RNDIS 的数据通道可能长时间空闲(用户没在传数据), - /// 设了超时就会不断地超时-重提交,每次重提交都是 3 次 IOKit 往返,纯属浪费。 - /// darwin 把 timeout 同时作为 noDataTimeout 与 completionTimeout 传给 IOKit, - /// 0 表示无限等待。 + /// **It must be 0 (infinite)**: the RNDIS data channel can be idle for a + /// long time (the user is not transferring anything), and with a timeout + /// set it would time out and resubmit over and over, every resubmission + /// costing 3 IOKit round trips -- pure waste. darwin passes the timeout to + /// IOKit as both noDataTimeout and completionTimeout, and 0 means wait + /// forever. std::uint32_t rx_timeout_millis = 0; - /// bulk OUT 的超时(毫秒)。TX 有超时是合理的 —— 设备卡住时我们要能察觉。 + /// The bulk OUT timeout (milliseconds). A timeout on TX is reasonable -- we + /// need to notice when the device gets stuck. std::uint32_t tx_timeout_millis = 5'000; }; -/// 一次 SendFrames 的结果。 +/// The result of one SendFrames. /// -/// 为什么不只返回一个数:曾经就是只返回「发出帧数」,结果两类被跳过的帧 -/// (超过设备 MaxTransferSize 的、短于以太头的)被算进了「发出」里 —— -/// 注释声称「由调用方从返回值推断丢弃」,而调用方根本推断不出来。 -/// 把「消费了多少」「真发出多少」「跳过多少」分开,账才能对上。 +/// Why not just return a single number: it used to return only "the number of +/// frames sent", and as a result two kinds of skipped frames (the ones +/// exceeding the device MaxTransferSize and the ones shorter than an +/// Ethernet header) were counted as "sent" -- the comment claimed "the caller +/// can infer the drops from the return value", and the caller simply could +/// not. Splitting "how much was consumed", "how much was really sent" and +/// "how much was skipped" is what makes the books balance. struct SendOutcome { - /// 调用方应把偏移前进这么多帧(含被跳过的)。0 表示传输池满,一帧都没进去。 + /// The caller should advance its offset by this many frames (including the + /// skipped ones). 0 means the transfer pool was full and not a single frame + /// went in. std::uint32_t consumed = 0; - /// 真正进入 bulk OUT 传输的帧数。 + /// The number of frames that really entered a bulk OUT transfer. std::uint32_t sent_frames = 0; - /// 上述帧的净荷字节合计(不含 RNDIS 头与填充)。 + /// The total payload bytes of those frames (excluding the RNDIS header and + /// the padding). std::uint64_t sent_bytes = 0; - /// 因长度非法(超长 / 短于以太头)被跳过的帧数。恒等于 consumed - sent_frames。 + /// The number of frames skipped because of an illegal length (too long / + /// shorter than an Ethernet header). Always equal to consumed - sent_frames. std::uint32_t skipped = 0; }; -/// 数据通道抽象。 +/// The data channel abstraction. /// -/// 抽象的粒度是**批**而不是帧:一次虚调用处理几十上百帧,摊到每帧的开销远小于 -/// 1 ns。这是刻意的 —— 若做成每帧一次虚调用就不可接受了。 +/// The granularity of the abstraction is the **batch**, not the frame: one +/// virtual call handles dozens to hundreds of frames, and the cost amortized +/// per frame is far below 1 ns. This is deliberate -- a virtual call per +/// frame would have been unacceptable. class DataChannel { public: DataChannel() = default; @@ -126,62 +154,77 @@ class DataChannel { DataChannel& operator=(DataChannel&&) = delete; virtual ~DataChannel() = default; - /// 启动接收:把解出的以太帧推进 `rx_ring`。 + /// Starts receiving: pushes the decoded Ethernet frames into `rx_ring`. /// - /// 调用后接收就一直在跑,直到 Shutdown()。 + /// Receiving keeps running after the call, until Shutdown(). [[nodiscard]] virtual Status StartReceiving(FrameRing& rx_ring, DirectionCounters& rx_counters) = 0; - /// 把一批以太帧聚合成 RNDIS 消息发给设备。**不阻塞。** + /// Aggregates a batch of Ethernet frames into RNDIS messages and sends them + /// to the device. **Does not block.** /// - /// `consumed < frames.size()` 表示传输池暂时没有空闲槽位(背压)。 - /// 调用方应保留剩余帧,用 WaitForSendCapacity() 等到有空槽再重试 —— - /// **不要丢弃**:上游(内核 BPF 缓冲)才是该吸收突发的弹性队列。 + /// `consumed < frames.size()` means the transfer pool has no free slot for + /// the moment (backpressure). The caller should keep the remaining frames + /// and wait with WaitForSendCapacity() until a slot is free and then retry + /// -- **do not drop them**: the upstream (the kernel BPF buffer) is the + /// elastic queue that should absorb bursts. [[nodiscard]] virtual Result SendFrames(std::span frames) = 0; - /// 等待 TX 方向出现空闲传输槽位。 + /// Waits for a free transfer slot to appear in the TX direction. /// - /// 返回 true 表示「值得再试一次 SendFrames」:有槽位空出来了,或通道已进入 - /// 停机(此时重试会得到错误,调用方自会退出)。false 表示等满超时仍无容量。 + /// Returning true means "it is worth calling SendFrames again": a slot has + /// become free, or the channel has entered shutdown (in which case the + /// retry gets an error and the caller exits on its own). false means the + /// timeout expired with still no capacity. /// - /// 只能由 SendFrames 的同一调用线程使用(TX 抽取线程)。实现必须保证 - /// Shutdown() 能唤醒正在等待的线程 —— 但调用方也不应依赖这一点做停机, - /// 应该用有限超时并在每次醒来后自查停机标志。 + /// Only the same calling thread as SendFrames may use it (the TX extractor + /// thread). The implementation must guarantee that Shutdown() can wake a + /// waiting thread -- but the caller should not rely on that for shutdown + /// either; it should use a finite timeout and re-check the shutdown flag + /// after every wakeup. [[nodiscard]] virtual bool WaitForSendCapacity(std::uint32_t timeout_millis) = 0; - /// 停止并回收全部在飞传输。 + /// Stops and reaps every in-flight transfer. /// - /// **不能从 libusb 事件线程调用**(会死锁,见文件头说明)。 + /// **Must not be called from the libusb event thread** (that deadlocks, see + /// the file header). virtual void Shutdown() = 0; - /// 当前 TX 是否有空闲传输槽位可用。 + /// Whether TX currently has a free transfer slot. [[nodiscard]] virtual bool CanSend() const noexcept = 0; - /// 单次传输能容纳的最大字节数(TX 方向)。 + /// The maximum number of bytes a single transfer can hold (TX direction). [[nodiscard]] virtual std::uint32_t MaxTransferBytes() const noexcept = 0; - /// 异步发送完成回调里累计的 I/O 错误数(STALL、传输失败等)。 + /// The I/O errors accumulated in the async send completion callback (STALL, + /// transfer failures, ...). /// - /// 为什么要单独开这个接口,而不是让数据通道直接写桥接层的 TX 计数器: - /// DirectionCounters 的契约是「**只允许一个线程**调用 Add*」(它用 relaxed - /// load+store 而非 fetch_add,正是靠单写者才安全)。而 TX 方向有两个写者 —— - /// 提交侧在 TX 抽取线程、错误侧在 libusb 事件线程。硬塞进同一个计数器就破坏了 - /// 这个不变式(TSan 也会报)。 - /// 因此拆成两份各自单写的计数,由 Bridge::Snapshot() 在**读取时**合并。 + /// Why this is a separate interface instead of letting the data channel + /// write the bridge's TX counters directly: the contract of + /// DirectionCounters is "**only one thread** may call Add*" (it uses a + /// relaxed load+store instead of fetch_add, and is safe precisely because + /// there is a single writer). But TX has two writers -- the submission + /// side on the TX extractor thread, the error side on the libusb event + /// thread. Forcing them into the same counter breaks that invariant (and + /// TSan reports it). + /// Hence two separately single-written counters, merged by + /// Bridge::Snapshot() **at read time**. [[nodiscard]] virtual std::uint64_t AsyncSendErrors() const noexcept = 0; }; -/// 基于 libusb 异步 API 的数据通道实现。 +/// A data channel implementation based on the libusb asynchronous API. class UsbDataChannel final : public DataChannel { public: - /// @param device 已声明好接口的设备 - /// @param parameters RNDIS 协商结果(决定聚合上限与对齐) - /// @param config 传输池参数 + /// @param device The device with its interfaces already claimed + /// @param parameters The RNDIS negotiation result (decides the aggregation + /// limit and the alignment) + /// @param config The transfer pool parameters [[nodiscard]] static Result> Create( Device& device, const rndis::NegotiatedParameters& parameters, const DataChannelConfig& config); - // 拷贝与移动已在基类 DataChannel 中删除,这里显式重申以满足静态检查。 + // Copy and move are already deleted in the base class DataChannel; they are + // restated here to satisfy the static checks. UsbDataChannel(const UsbDataChannel&) = delete; UsbDataChannel& operator=(const UsbDataChannel&) = delete; UsbDataChannel(UsbDataChannel&&) = delete; @@ -204,7 +247,8 @@ class UsbDataChannel final : public DataChannel { return async_send_errors_.load(std::memory_order_relaxed); } - /// 累计因 RNDIS 消息畸形而丢弃的传输数,用于诊断设备 bug。 + /// The number of transfers dropped because of a malformed RNDIS message, + /// used to diagnose device bugs. [[nodiscard]] std::uint64_t MalformedTransfers() const noexcept { return malformed_transfers_.load(std::memory_order_relaxed); } @@ -212,7 +256,7 @@ class UsbDataChannel final : public DataChannel { private: UsbDataChannel() = default; - /// 池中的一个传输及其缓冲。 + /// One transfer of the pool together with its buffer. struct Slot { ::libusb_transfer* transfer = nullptr; std::byte* buffer = nullptr; @@ -221,22 +265,23 @@ class UsbDataChannel final : public DataChannel { std::uint32_t index = 0; }; - // ---- libusb 回调(静态跳板 → 成员函数)---- + // ---- libusb callbacks (static trampolines -> member functions) ---- static void ReceiveCallbackTrampoline(::libusb_transfer* transfer); static void SendCallbackTrampoline(::libusb_transfer* transfer); void OnReceiveComplete(Slot& slot) noexcept; void OnSendComplete(Slot& slot) noexcept; - /// 分配一个池。缓冲按页对齐(darwin 上没有零拷贝 DMA 缓冲, - /// libusb_dev_mem_alloc 返回 NULL,只能自己 posix_memalign;按 hw.pagesize - /// 对齐能减少 IOKit 建立 DMA 描述符时的分段)。 + /// Allocates one pool. The buffers are page aligned (darwin has no + /// zero-copy DMA buffers, libusb_dev_mem_alloc returns NULL, so we have to + /// use posix_memalign ourselves; aligning to hw.pagesize reduces the + /// fragmentation when IOKit builds the DMA descriptors). [[nodiscard]] Status AllocatePool(std::vector& pool, std::uint32_t count, std::uint32_t buffer_bytes); static void FreePool(std::vector& pool) noexcept; - /// 提交一个 RX 传输。 + /// Submits one RX transfer. [[nodiscard]] Status SubmitReceive(Slot& slot); Device* device_ = nullptr; @@ -248,39 +293,47 @@ class UsbDataChannel final : public DataChannel { std::vector rx_pool_; std::vector tx_pool_; - /// TX 空闲槽位索引队列。 + /// The queue of free TX slot indices. /// - /// 生产者 = libusb 事件线程(发送完成回调归还槽位), - /// 消费者 = BPF 读线程(取槽位组装下一批)。正好是 SPSC。 + /// The producer is the libusb event thread (the send completion callback + /// returns the slot), the consumer is the BPF read thread (takes a slot to + /// assemble the next batch). Exactly an SPSC. std::unique_ptr> tx_free_slots_; - /// 接收目标。StartReceiving 之后非空。 + /// The receive destination. Non-null after StartReceiving. FrameRing* rx_ring_ = nullptr; DirectionCounters* rx_counters_ = nullptr; - /// 异步发送完成回调里累计的 I/O 错误。**只由 libusb 事件线程递增**, - /// 与桥接层的 TX 计数器分开,见 AsyncSendErrors() 的说明。 + /// The I/O errors accumulated in the async send completion callback. + /// **Incremented by the libusb event thread only**, kept separate from the + /// bridge's TX counters; see the description of AsyncSendErrors(). std::atomic async_send_errors_{0}; - /// 在飞传输计数。回调递减,Shutdown 等它归零。 + /// The in-flight transfer count. The callbacks decrement it, Shutdown waits + /// for it to reach zero. std::atomic outstanding_{0}; - /// 停机标志。回调看到它就不再 resubmit。 + /// The shutdown flag. Once the callback sees it, it stops resubmitting. std::atomic shutting_down_{false}; - /// 是否已经完成拆除(Shutdown 幂等)。 + /// Whether the teardown has already completed (Shutdown is idempotent). bool shutdown_complete_ = false; std::atomic malformed_transfers_{0}; - /// 等待在飞计数归零。 + /// Waits for the in-flight count to reach zero. std::mutex drain_mutex_; std::condition_variable drain_condition_; - /// TX 空槽等待。 + /// The wait for a free TX slot. /// - /// 谓词的真值源是 tx_free_slots_(无锁 SPSC 环),这对互斥量+条件变量只是 - /// 唤醒机制:完成回调归还槽位后取一下锁再 notify(空临界区惯用法),保证 - /// 等待方要么在谓词检查里看到新槽位、要么已进入 wait 能收到通知,不会漏醒。 - /// 完成回调在 libusb 事件线程上,这把锁只被瞬间持有,不构成「回调阻塞」。 + /// The source of truth for the predicate is tx_free_slots_ (the lock-free + /// SPSC ring), which makes the mutex + condition variable nothing but a + /// wakeup mechanism: the completion callback returns the slot, then takes + /// the lock and notifies (the empty critical section idiom), which + /// guarantees the waiter either sees the new slot in the predicate check or + /// has already entered wait and will get the notification -- no missed + /// wakeup. The completion callback runs on the libusb event thread and + /// holds this lock only momentarily, so it does not amount to "the callback + /// blocks". std::mutex send_capacity_mutex_; std::condition_variable send_capacity_cv_; }; diff --git a/include/tetherkit/usb/device.h b/include/tetherkit/usb/device.h index d8264c4..fe65338 100644 --- a/include/tetherkit/usb/device.h +++ b/include/tetherkit/usb/device.h @@ -1,9 +1,10 @@ -// RNDIS USB 设备的发现、声明与端点解析。 +// Discovery, claiming and endpoint parsing for RNDIS USB devices. // -// ★ macOS 上能不能声明 RNDIS 接口?—— 能,而且不需要 root ★ +// Can a RNDIS interface be claimed on macOS? -- Yes, and it needs no root // -// 这是本项目最关键的可行性前提,结论来自逐个检查 /System/Library/Extensions -// 下所有 CDC 家族 kext 的 IOKitPersonalities: +// This is the most critical feasibility premise of the project. The +// conclusion comes from checking the IOKitPersonalities of every +// CDC-family kext under /System/Library/Extensions one by one: // // AppleUSBACMControl0 = {class 2, subclass 2, protocol 0} // AppleUSBACMControl1 = {class 2, subclass 2, protocol 1} @@ -11,34 +12,43 @@ // AppleUSBNCMControl = {class 2, subclass 13, protocol *} // AppleUSBWCMControl = {class 2, subclass 8, protocol *} // -// RNDIS 的通信类接口是 {class 0x02, subclass 0x02, protocol **0xFF**} —— -// protocol 既不是 0 也不是 1,**没有任何 personality 匹配**。Android 常用的 -// {0xE0, 0x01, 0x03} 变体更是连 class 0xE0 的 personality 都不存在。 -// 也就是说 **macOS 内核根本没有 RNDIS 驱动**(这正是 HoRNDIS 这类第三方 -// kext 存在的原因,也正是本项目存在的理由)。 +// The RNDIS communication-class interface is {class 0x02, subclass 0x02, +// protocol **0xFF**} -- the protocol is neither 0 nor 1, so **no personality +// matches at all**. The {0xE0, 0x01, 0x03} variant commonly used by Android +// does not even have a personality for class 0xE0. +// In other words, **the macOS kernel has no RNDIS driver whatsoever** (which +// is exactly why third-party kexts such as HoRNDIS exist, and exactly why +// this project exists). // -// 数据类接口 {0x0A, 0x00, 0x00} 确实会被 AppleUSBECMData0 / AppleUSBACMData0 -// probe 到,但它们的 start() 需要在同一设备上找到配对的 Control 驱动 -// (通过 CDC Union 描述符的 bMasterInterface);RNDIS 场景下通信接口没被任何 -// Control 驱动接管,配对查找失败 → start() 返回 false → 驱动脱离, -// 该接口的 IORegistry 节点最终没有子节点,libusb 的 -// darwin_kernel_driver_active() 会返回 0。 +// The data-class interface {0x0A, 0x00, 0x00} really is probed by +// AppleUSBECMData0 / AppleUSBACMData0, but their start() needs to find the +// matching Control driver on the same device (through the bMasterInterface +// of the CDC Union descriptor); in the RNDIS scenario the communication +// interface is not taken over by any Control driver, so the pairing lookup +// fails -> start() returns false -> the driver detaches, the IORegistry node +// of that interface ends up without children, and libusb's +// darwin_kernel_driver_active() returns 0. // -// 设备级会有 AppleUSBCDCCompositeDevice 挂上(它的 IOProviderClass 是 -// IOUSBHostDevice,匹配面很宽),但它只做 ConfigureDevice / 发布 interface, -// **不 open 任何 interface**,因此不影响 USBInterfaceOpen。 +// At the device level AppleUSBCDCCompositeDevice does attach (its +// IOProviderClass is IOUSBHostDevice, so it matches broadly), but it only +// does ConfigureDevice / publishes the interface and **opens no interface** +// at all, so it does not affect USBInterfaceOpen. // -// → 结论:普通非沙箱命令行程序**不需要 root、不需要 entitlement**, -// libusb_open + libusb_claim_interface 即可成功。 -// (本项目整体仍需 root,但那是 feth 与 BPF 的要求,不是 libusb 的。) +// -> Conclusion: an ordinary non-sandboxed command line program **needs +// neither root nor an entitlement**; libusb_open + libusb_claim_interface +// succeed. +// (The project as a whole still needs root, but that is a requirement of +// feth and BPF, not of libusb.) // -// ★ 绝对不要开 libusb_set_auto_detach_kernel_driver ★ +// Never enable libusb_set_auto_detach_kernel_driver // -// 在 macOS 上它会让 claim 走 darwin_capture_claim_interface,一旦 -// darwin_kernel_driver_active 判为真就去「detach」—— 而 darwin 的 detach -// 实现是**重新枚举整个设备**(USBDeviceReEnumerate + CaptureDeviceMask), -// 且需要 com.apple.vm.device-access entitlement 或 root,否则返回 -// LIBUSB_ERROR_ACCESS。RNDIS 场景下本来没人占接口,开它纯属引入破坏性操作。 +// On macOS it makes the claim go through darwin_capture_claim_interface, +// which "detaches" as soon as darwin_kernel_driver_active reports true -- +// and darwin's detach implementation is **a full re-enumeration of the +// device** (USBDeviceReEnumerate + CaptureDeviceMask), and it needs the +// com.apple.vm.device-access entitlement or root, otherwise it returns +// LIBUSB_ERROR_ACCESS. In the RNDIS scenario nobody holds the interface to +// begin with, so enabling it only introduces a destructive operation. #pragma once #include @@ -57,60 +67,71 @@ namespace tetherkit::usb { -/// 一个被识别为 RNDIS 的候选设备。 +/// A candidate device recognized as RNDIS. struct DeviceCandidate { std::uint16_t vendor_id = 0; std::uint16_t product_id = 0; std::uint8_t bus_number = 0; std::uint8_t device_address = 0; - /// 通信类接口(承载控制通道 + 中断通知)。 + /// The communication-class interface (carries the control channel and the + /// interrupt notifications). std::uint8_t control_interface = 0; - /// 数据类接口(承载 bulk IN/OUT)。 + /// The data-class interface (carries bulk IN/OUT). std::uint8_t data_interface = 0; - /// 匹配到的接口签名,用于日志说明「按哪种 RNDIS 形态识别的」。 + /// The matched interface signature, used in the log to say "which RNDIS + /// shape this was recognized as". rndis::InterfaceSignature signature{}; - /// 是否走了 Android quirk 的兜底路径(见 DeviceFinder 的注释)。 + /// Whether the Android quirk fallback path was taken (see the comments of + /// DeviceFinder). bool used_android_quirk = false; - /// 形如 "Bus 020 Device 003: 18d1:4ee4"。 + /// Of the form "Bus 020 Device 003: 18d1:4ee4". [[nodiscard]] std::string Describe() const; }; -/// 设备筛选条件。全为 0 / 空表示不限。 +/// Device filter criteria. All-zero / empty means no restriction. struct DeviceFilter { std::uint16_t vendor_id = 0; std::uint16_t product_id = 0; - /// 只匹配指定总线上的指定地址(两者必须同时给出)。 + /// Match only the given address on the given bus (both must be given). std::uint8_t bus_number = 0; std::uint8_t device_address = 0; }; -/// 枚举当前连接的、看起来像 RNDIS 的设备。 +/// Enumerates the connected devices that look like RNDIS. /// -/// 识别逻辑(顺序即优先级): -/// 1. 遍历所有配置的所有接口,找 signature 命中 kControlSignature* 的接口; -/// 2. 排除伪 RNDIS:class == 0x02 且带非零 CDC ACM bmCapabilities 的是真 -/// cdc-acm 调制解调器,不是 RNDIS。**这条检查只对 class 0x02 生效** —— -/// 无线类(0xE0)的 RNDIS function 会把 bmCapabilities 挪作自用。 -/// 3. 找配对的数据接口:优先读 CDC Union 功能描述符的 bSlaveInterface; -/// 4. **Android quirk**:许多 Android 设备的 CDC Union 描述符指向不存在的 -/// 接口号,或者干脆缺少 CDC 功能描述符。此时回落到「通信接口 = 0、 -/// 数据接口 = 1」的硬编码假设(Linux 的 android_rndis_quirk 就是这么做的), -/// 并要求通信接口确实是 0。 +/// Recognition logic (the order is the priority): +/// 1. Walk every interface of every configuration and find the interfaces +/// whose signature hits kControlSignature*; +/// 2. Rule out fake RNDIS: a class == 0x02 interface carrying nonzero CDC +/// ACM bmCapabilities is a real cdc-acm modem, not RNDIS. **This check +/// only applies to class 0x02** -- the wireless-class (0xE0) RNDIS +/// function repurposes bmCapabilities for its own use. +/// 3. Find the paired data interface: prefer the bSlaveInterface of the CDC +/// Union functional descriptor; +/// 4. **Android quirk**: on many Android devices the CDC Union descriptor +/// points at a nonexistent interface number, or the CDC functional +/// descriptor is missing altogether. Fall back to the hardcoded +/// assumption "communication interface = 0, data interface = 1" (this is +/// what Linux's android_rndis_quirk does), and require the communication +/// interface to really be 0. [[nodiscard]] Result> FindRndisDevices(const Context& context, const DeviceFilter& filter = {}); -/// 已打开并声明好接口的 RNDIS 设备。 +/// An RNDIS device that has been opened and whose interfaces are claimed. /// -/// RAII:析构时按「释放接口 → 关闭句柄」的顺序拆除。 -/// **调用方必须保证所有异步 transfer 在本对象析构前已经全部回收完毕** —— -/// libusb_close 不会帮你回收在飞 transfer(它只是把 transfer->dev_handle 置空 -/// 并打一条 usbi_err),之后 IOKit 中止仍会让回调在 libusb 内部线程上跑, -/// 若那时 transfer 已被 free 就是 use-after-free。见 TransferPool 的拆除算法。 +/// RAII: the destructor tears down in the order "release interfaces -> close +/// the handle". +/// **The caller must guarantee that every asynchronous transfer has already +/// been reaped before this object is destroyed** -- libusb_close does not +/// reap in-flight transfers for you (it merely nulls transfer->dev_handle and +/// logs a usbi_err), and the later IOKit abort still lets the callback run on +/// an internal libusb thread; if the transfer has been freed by then, that is +/// a use-after-free. See the teardown algorithm of TransferPool. class Device { public: - /// 打开设备并声明两个接口。 + /// Opens the device and claims both interfaces. [[nodiscard]] static Result> Open(const Context& context, const DeviceCandidate& candidate); @@ -128,48 +149,55 @@ class Device { [[nodiscard]] std::string_view Describe() const noexcept { return description_; } // --------------------------------------------------------------------------- - // 端点 + // Endpoints // --------------------------------------------------------------------------- - /// bulk IN 端点地址(设备 → 主机)。 + /// The bulk IN endpoint address (device -> host). [[nodiscard]] std::uint8_t BulkInEndpoint() const noexcept { return bulk_in_endpoint_; } - /// bulk OUT 端点地址(主机 → 设备)。 + /// The bulk OUT endpoint address (host -> device). [[nodiscard]] std::uint8_t BulkOutEndpoint() const noexcept { return bulk_out_endpoint_; } - /// 中断 IN 端点地址;0 表示设备没有中断端点(合法,需退化为轮询)。 + /// The interrupt IN endpoint address; 0 means the device has no interrupt + /// endpoint (legal, degrades to polling). [[nodiscard]] std::uint8_t InterruptInEndpoint() const noexcept { return interrupt_in_endpoint_; } - /// bulk 端点的 wMaxPacketSize。 + /// The wMaxPacketSize of the bulk endpoints. /// - /// 用途有两处:① 推导 INITIALIZE_MSG 里该宣称的 MaxTransferSize; - /// ② 判断 bulk OUT 传输长度是否恰为它的整数倍,从而决定要不要补 1 字节规避 ZLP。 + /// It has two uses: (1) derive the MaxTransferSize to advertise in + /// INITIALIZE_MSG; (2) decide whether a bulk OUT transfer length is an + /// exact multiple of it, which in turn decides whether a 1-byte padding is + /// needed to dodge the ZLP. [[nodiscard]] std::uint16_t BulkMaxPacketSize() const noexcept { return bulk_max_packet_size_; } - /// 中断端点的 wMaxPacketSize(RNDIS 通知固定 8 字节)。 + /// The wMaxPacketSize of the interrupt endpoint (RNDIS notifications are + /// fixed at 8 bytes). [[nodiscard]] std::uint16_t InterruptMaxPacketSize() const noexcept { return interrupt_max_packet_size_; } - /// 设备的 USB 速度,用于日志与吞吐预期。 + /// The USB speed of the device, used for logging and throughput + /// expectations. [[nodiscard]] int Speed() const noexcept { return speed_; } [[nodiscard]] std::string_view SpeedName() const noexcept; - /// 清除某个端点的 halt 状态。 + /// Clears the halt state of an endpoint. /// - /// STALL 恢复可以在 transfer 回调里直接调用:libusb_clear_halt 走 - /// darwin_clear_halt → ClearPipeStallBothEnds,是一次同步 IOKit 调用, - /// 不经过事件循环、没有 usbi_handling_events 守卫。代价是会阻塞事件线程 - /// 几十微秒到毫秒级,所以只在真的 STALL 时调。 + /// Recovering from a STALL can be done right inside the transfer callback: + /// libusb_clear_halt goes through darwin_clear_halt -> ClearPipeStallBothEnds, + /// which is a synchronous IOKit call; it does not run through the event loop + /// and has no usbi_handling_events guard. The cost is that it blocks the + /// event thread for tens of microseconds up to milliseconds, so it is only + /// called on a real STALL. [[nodiscard]] Status ClearHalt(std::uint8_t endpoint); private: Device() = default; - /// 从接口描述符里解析出三个端点。 + /// Parses the three endpoints out of the interface descriptors. [[nodiscard]] Status ResolveEndpoints(const ::libusb_config_descriptor& config); ::libusb_device_handle* handle_ = nullptr; @@ -187,33 +215,41 @@ class Device { int speed_ = 0; }; -/// 基于 libusb 的控制通道实现。 +/// A control channel implementation based on libusb. /// -/// **必须从一个非 libusb 事件线程的专用线程上使用** —— 同步 API -/// (libusb_control_transfer)在事件线程上会返回 LIBUSB_ERROR_BUSY -/// (usbi_handling_events 是 TLS 判断)。 +/// **Must be used from a dedicated thread that is not a libusb event +/// thread** -- the synchronous API (libusb_control_transfer) returns +/// LIBUSB_ERROR_BUSY on the event thread (usbi_handling_events is a TLS +/// check). /// -/// ★ 中断通知走**异步**传输,这不是优化而是必须 ★ +/// Interrupt notifications go through an **asynchronous** transfer; this is +/// not an optimization but a necessity /// -/// 若 WaitForNotification 改用同步 libusb_interrupt_transfer, -/// **控制线程会永久卡死**,栈是: -/// Poll → WaitForNotification → do_sync_bulk_transfer -/// → sync_transfer_wait_for_completion → handle_events → poll(∞) +/// If WaitForNotification were switched to the synchronous +/// libusb_interrupt_transfer, the **control thread would hang forever**, +/// with this stack: +/// Poll -> WaitForNotification -> do_sync_bulk_transfer +/// -> sync_transfer_wait_for_completion -> handle_events -> poll(infinity) /// -/// 原因链: -/// 1. darwin 后端给所有 transfer 打 USBI_TRANSFER_OS_HANDLES_TIMEOUT, -/// 表示「超时交给 IOKit 管,libusb 自己不计时」;于是 -/// libusb_get_next_timeout 跳过它们、返回「无超时」, -/// sync_transfer_wait_for_completion 里的 poll() 无限期阻塞; -/// 2. 而 IOKit 侧,darwin 的 submit_interrupt_transfer 用的是 -/// **ReadPipeAsync(无超时变体)**,不是 bulk 用的 ReadPipeAsyncTO —— -/// **中断传输的 timeout 参数根本不被遵守**; -/// 3. 于是端点上没有数据时,这个同步传输永远不会完成。 +/// The chain of causes: +/// 1. The darwin backend tags every transfer with +/// USBI_TRANSFER_OS_HANDLES_TIMEOUT, meaning "the timeout is IOKit's +/// business, libusb does not time it itself"; so libusb_get_next_timeout +/// skips them and reports "no timeout", and the poll() inside +/// sync_transfer_wait_for_completion blocks indefinitely; +/// 2. On the IOKit side, darwin's submit_interrupt_transfer uses +/// **ReadPipeAsync (the variant without a timeout)**, not the +/// ReadPipeAsyncTO used for bulk -- **the timeout parameter of an +/// interrupt transfer is simply not honored**; +/// 3. So when the endpoint has no data, this synchronous transfer never +/// completes. /// -/// 把 timeout 从 0 改成 1 ms 是**没用的**(第 2 条决定了它对中断端点无效)。 -/// 唯一的正确解法是: -/// 提交一个常驻的**异步**中断传输,让它在 libusb 事件线程上完成, -/// WaitForNotification 只查一个原子标志,永不进入 libusb 的等待路径。 +/// Changing the timeout from 0 to 1 ms is **useless** (point 2 decides that +/// it does not apply to interrupt endpoints). +/// The only correct solution is: +/// submit a permanent **asynchronous** interrupt transfer, let it complete +/// on the libusb event thread, and have WaitForNotification only check an +/// atomic flag, never entering libusb's waiting path. class UsbControlChannel final : public rndis::ControlChannel { public: UsbControlChannel(Device& device, std::uint32_t timeout_millis); @@ -225,10 +261,12 @@ class UsbControlChannel final : public rndis::ControlChannel { ~UsbControlChannel() override; - /// 提交常驻的异步中断传输。设备没有中断端点时是空操作。 + /// Submits the permanent asynchronous interrupt transfer. A no-op when the + /// device has no interrupt endpoint. [[nodiscard]] Status StartNotificationListener(); - /// 取消中断传输并等回调回收完毕。**不能从 libusb 事件线程调用。** + /// Cancels the interrupt transfer and waits for the callback to be + /// reaped. **Must not be called from the libusb event thread.** void StopNotificationListener(); [[nodiscard]] Status SendMessage(std::span message) override; @@ -248,16 +286,18 @@ class UsbControlChannel final : public rndis::ControlChannel { Device* device_; std::uint32_t timeout_millis_; - /// GET_ENCAPSULATED_RESPONSE 的接收缓冲。 + /// The receive buffer for GET_ENCAPSULATED_RESPONSE. std::vector response_buffer_; - /// 中断 IN 的通知缓冲(8 字节)。 + /// The notification buffer of the interrupt IN (8 bytes). std::vector notification_buffer_; - /// 常驻的异步中断传输。 + /// The permanent asynchronous interrupt transfer. ::libusb_transfer* notification_transfer_ = nullptr; - /// 设备是否通报了「有响应可取」。由事件线程置位,控制线程消费。 + /// Whether the device has announced "there is a response to take". Set by + /// the event thread, consumed by the control thread. std::atomic notification_pending_{false}; - /// 中断传输是否在飞。用于停机时等待回调回收(避免 use-after-free)。 + /// Whether the interrupt transfer is in flight. Used to wait for the + /// callback to be reaped during shutdown (avoiding a use-after-free). std::atomic notification_in_flight_{false}; std::atomic notification_stopping_{false}; }; diff --git a/include/tetherkit/version.h b/include/tetherkit/version.h index f1bbe9c..34b0925 100644 --- a/include/tetherkit/version.h +++ b/include/tetherkit/version.h @@ -1,7 +1,8 @@ -// TetherKit 版本信息。 +// TetherKit version information. // -// 版本号由 CMake 在配置阶段注入到 version.cc,头文件只暴露访问函数, -// 避免每次改版本号都触发全项目重编译。 +// The version number is injected into version.cc by CMake at configure time; +// this header only exposes accessors, so bumping the version does not trigger +// a full project rebuild. #pragma once #include @@ -9,23 +10,24 @@ namespace tetherkit { -/// 语义化版本号的三段数字。 +/// The three components of the semantic version number. struct Version { std::uint16_t major; std::uint16_t minor; std::uint16_t patch; }; -/// 返回编译期烧入的版本号。 +/// Returns the version number burned in at compile time. Version GetVersion() noexcept; -/// 返回形如 "TetherKit 0.1.0 (C++23, macOS 13.3+)" 的可读版本串。 +/// Returns a human-readable version string like "TetherKit 0.1.0 (C++23, macOS 13.3+)". std::string_view GetVersionString() noexcept; -/// 返回构建配置描述,形如 -/// "RelWithDebInfo, AppleClang 21.0.0, C++23, LTO=off, sanitizers=none"。 +/// Returns a build description like +/// "RelWithDebInfo, AppleClang 21.0.0, C++23, LTO=off, sanitizers=none". /// -/// 性能基准报告必须带上它 —— 脱离构建配置的 ns/op 数字没有意义。 +/// Benchmark reports must include it -- an ns/op number without the build +/// configuration is meaningless. std::string_view GetBuildDescription() noexcept; } // namespace tetherkit diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8636764..9875679 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,20 +1,28 @@ -# src —— 各功能模块 +# src -- the feature modules # -# 依赖方向严格单向,禁止反向依赖或循环依赖: +# The dependency direction is strictly one-way; reverse or circular +# dependencies are forbidden: # -# tk_common ← 无依赖(日志、错误、无锁容器、内存池、统计) -# ↑ -# tk_rndis ← common (RNDIS 线格式与状态机,纯逻辑,不碰 I/O) -# tk_net ← common (feth 虚拟网卡 + BPF 链路,仅 macOS 系统调用) -# tk_usb ← common (libusb 封装) -# ↑ -# tk_core ← common+rndis+net+usb (数据路径桥接与运行时编排) -# ↑ -# tetherkit ← core (命令行可执行程序) -# tk_capi ← core (C ABI,供 Swift GUI 使用) +# tk_common <- no dependencies (logging, errors, lock-free containers, +# memory pool, statistics) +# | +# v +# tk_rndis <- common (RNDIS wire format and state machine, pure +# logic, no I/O) +# tk_net <- common (feth virtual interface + BPF link, macOS +# syscalls only) +# tk_usb <- common (libusb wrapper) +# | +# v +# tk_core <- common+rndis+net+usb (data-path bridging and runtime +# orchestration) +# | +# v +# tetherkit <- core (command-line executable) +# tk_capi <- core (C ABI, consumed by the Swift GUI) # ------------------------------------------------------------------------------ -# 公共编译属性:所有本项目目标都继承它 +# Common compile properties inherited by every target in this project. # ------------------------------------------------------------------------------ add_library(tetherkit_base INTERFACE) target_include_directories(tetherkit_base INTERFACE "${PROJECT_SOURCE_DIR}/include") @@ -22,7 +30,8 @@ target_link_libraries(tetherkit_base INTERFACE tetherkit_warnings tetherkit_sani tetherkit_optimizations) # ------------------------------------------------------------------------------ -# 版本号:从模板生成,避免版本变更导致全量重编译 +# Version: generated from the template so that a version bump does not force a +# full rebuild. # ------------------------------------------------------------------------------ configure_file("${CMAKE_CURRENT_SOURCE_DIR}/version.cc.in" "${CMAKE_CURRENT_BINARY_DIR}/version.cc" @ONLY) diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index ddb6057..6b95ccd 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -1,12 +1,13 @@ -# app —— 命令行可执行程序 tetherkit-cli +# app -- the command-line executable tetherkit-cli # -# CMake 目标名保持 tetherkit(`cmake --build build --target tetherkit` 的用法 -# 与缓存都不受影响),**产物文件名**是 tetherkit-cli:Homebrew 上的 `tetherkit` -# 这个名字让位给了图形界面 formula,命令行以 tetherkit-cli 分发。 +# The CMake target name stays tetherkit (the usage +# `cmake --build build --target tetherkit` and the CMake cache are unaffected); +# the **output file name** is tetherkit-cli: on Homebrew the `tetherkit` name +# was claimed by the GUI formula, so the CLI ships as tetherkit-cli. add_executable(tetherkit main.cc) set_target_properties(tetherkit PROPERTIES OUTPUT_NAME tetherkit-cli) -# tk_core PUBLIC 链接了其余全部模块。 +# tk_core PUBLIC-links all of the other modules. target_link_libraries(tetherkit PRIVATE tetherkit_base tk_version tk_core) install(TARGETS tetherkit RUNTIME DESTINATION bin) diff --git a/src/app/main.cc b/src/app/main.cc index 69318d4..da5bf60 100644 --- a/src/app/main.cc +++ b/src/app/main.cc @@ -1,8 +1,14 @@ -// TetherKit 命令行入口。 +// TetherKit command-line entry point. // -// 职责刻意保持很薄:解析参数 → 装配 RuntimeConfig → 交给 core::Runtime → -// 处理信号。所有实质逻辑都在库里,这样才能被单元测试覆盖。 -#include // NOLINT(modernize-deprecated-headers) —— sigaction 只在此头文件里 +// Kept deliberately thin: parse arguments -> assemble a RuntimeConfig -> hand +// it to core::Runtime -> handle signals. All substantive logic lives in the +// library so it can be covered by unit tests. +#include "tetherkit/common/i18n.h" +#include "tetherkit/common/logging.h" +#include "tetherkit/core/runtime.h" +#include "tetherkit/version.h" + +#include // NOLINT(modernize-deprecated-headers) -- sigaction lives only in this header #include #include @@ -15,11 +21,6 @@ #include #include -#include "tetherkit/common/i18n.h" -#include "tetherkit/common/logging.h" -#include "tetherkit/core/runtime.h" -#include "tetherkit/version.h" - namespace { using tetherkit::Error; @@ -31,31 +32,35 @@ using tetherkit::Status; using tetherkit::Text; using tetherkit::Tr; -/// 全局停机标志。 +/// Global shutdown flag. /// -/// 进程级的信号状态天然是全局的,无法避免;用无锁原子保证安全后不再包装。 +/// Process-level signal state is inherently global and unavoidable; with a +/// lock-free atomic making it safe, no further wrapping is needed. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -/// **信号处理器里只能碰 volatile sig_atomic_t 或无锁原子**,绝不能加锁、 -/// 分配内存或打日志 —— 那些都不是异步信号安全的。这里只写一个原子, -/// 真正的停机由主线程在看到它之后执行。 +/// **Only volatile sig_atomic_t or lock-free atomics may be touched from a +/// signal handler** -- never locks, allocation or logging: none of those are +/// async-signal-safe. We write a single atomic here; the real shutdown runs on +/// the main thread once it observes it. std::atomic g_stop_requested{false}; extern "C" void HandleSignal(int /*signal_number*/) { g_stop_requested.store(true, std::memory_order_release); } -/// 安装信号处理器。 +/// Install the signal handlers. /// -/// 用 sigaction 而非 signal():后者的语义在各 Unix 上不一致, -/// 而且默认会在处理器执行期间重置为 SIG_DFL。 +/// sigaction rather than signal(): the latter has inconsistent semantics +/// across Unixes and resets to SIG_DFL by default while the handler runs. [[nodiscard]] Status InstallSignalHandlers() { struct sigaction action{}; action.sa_handler = &HandleSignal; - // 注意:macOS 上 sigemptyset 是**宏**(#define sigemptyset(set) (*(set)=0,0)), - // 不能写成 ::sigemptyset —— 宏名无法被作用域限定。 + // Note: on macOS sigemptyset is a **macro** + // (#define sigemptyset(set) (*(set)=0,0)), so it cannot be written as + // ::sigemptyset -- macro names cannot be scope-qualified. sigemptyset(&action.sa_mask); - // 刻意**不设** SA_RESTART:我们希望信号能打断阻塞的系统调用(例如 BPF 的 - // read()),让线程有机会看到停机标志。 + // Deliberately **do not set** SA_RESTART: we want signals to interrupt + // blocking syscalls (BPF's read() for example) so threads get a chance to + // observe the shutdown flag. action.sa_flags = 0; for (const int signal_number : {SIGINT, SIGTERM, SIGHUP}) { @@ -65,7 +70,8 @@ extern "C" void HandleSignal(int /*signal_number*/) { } } - // 忽略 SIGPIPE:往已关闭的 BPF 描述符写会触发它,默认行为是终止进程。 + // Ignore SIGPIPE: writing to a closed BPF descriptor triggers it and the + // default action terminates the process. struct sigaction ignore{}; ignore.sa_handler = SIG_IGN; sigemptyset(&ignore.sa_mask); @@ -76,13 +82,14 @@ extern "C" void HandleSignal(int /*signal_number*/) { } // ============================================================================= -// 命令行解析 +// Command-line parsing // ============================================================================= -/// 把一条不带参数的文案原样写到 stdout。 +/// Write a parameterless user-facing string verbatim to stdout. /// -/// 用 fwrite 而不是 fputs:`Text()` 返回的 string_view 指向表里的字面量, -/// 虽然实际上带 NUL,但 string_view 的契约里没有这一条,别养成坏习惯。 +/// fwrite rather than fputs: the string_view from `Text()` points at literals +/// in the table, and although they happen to be NUL-terminated, that is not +/// part of the string_view contract -- do not form bad habits. void PrintText(Msg id) { const std::string_view text = Text(id); std::fwrite(text.data(), 1, text.size(), stdout); @@ -92,16 +99,18 @@ void PrintUsage() { PrintText(Msg::kCliUsage); } -/// 在正式解析参数**之前**先把 `--lang` 挑出来生效。 +/// Pull `--lang` out and apply it **before** formal argument parsing. /// -/// 为什么要单独扫一遍:帮助文本、以及解析过程中自己产生的报错,都得用用户要的 -/// 那种语言。等 ParseArguments 顺序走到 `--lang` 时,前面几个选项的错误消息 -/// 已经用旧语言发出去了。 +/// Why a separate pass: the help text, and the errors ParseArguments itself +/// emits along the way, must all be in the language the user asked for. By the +/// time ParseArguments reaches `--lang` in order, messages for earlier options +/// have already gone out in the old language. /// -/// 只认 `--lang <值>` 这一种写法(全项目的选项都不支持 `--opt=值`,不在这里 -/// 破例,否则 ParseArguments 会把 `--lang=en` 当成未知选项,反而更费解)。 -/// 值非法时这里不报错,留给 ParseArguments 去报 —— 那时报错本身已经是 -/// 用户要的语言了。 +/// Only the `--lang ` spelling is accepted (no option in this project +/// supports `--opt=value`, and making an exception here would only confuse +/// ParseArguments, which would treat `--lang=en` as an unknown option). An +/// invalid value is not reported here -- ParseArguments reports it, by which +/// point the error itself is already in the user's language. void ApplyLanguageOption(int argc, char** argv) { for (int i = 1; i + 1 < argc; ++i) { if (std::string_view{argv[i]} != "--lang") { @@ -114,7 +123,7 @@ void ApplyLanguageOption(int argc, char** argv) { } } -/// 解析一个无符号整数参数。 +/// Parse an unsigned integer argument. [[nodiscard]] Result ParseUint(std::string_view text, int base = 10) { std::uint32_t value = 0; const char* begin = text.data(); @@ -148,7 +157,7 @@ void ApplyLanguageOption(int argc, char** argv) { return std::unexpected(Error::Generic(Tr(Msg::kCliUnknownLogLevel, text))); } -/// 解析结果。 +/// Result of parsing. struct ParsedArguments { tetherkit::core::RuntimeConfig config; bool show_help = false; @@ -159,7 +168,7 @@ struct ParsedArguments { [[nodiscard]] Result ParseArguments(int argc, char** argv) { ParsedArguments parsed; - // 取一个需要值的选项的值。 + // Take the value of an option that requires one. const auto take_value = [argc, argv](int& index, std::string_view option) -> Result { if (index + 1 >= argc) { @@ -255,8 +264,9 @@ struct ParsedArguments { continue; } if (argument == "--lang") { - // 值已经由 ApplyLanguageOption 在解析开始前生效了(那样连本轮的报错 - // 都是目标语言)。这里只把它消费掉并复核一次拼写。 + // The value was already applied by ApplyLanguageOption before parsing + // began (so that even this round's errors come out in the target + // language). Here we only consume it and re-check the spelling. TETHERKIT_ASSIGN_OR_RETURN(const auto text, take_value(i, argument)); Language ignored{}; if (!tetherkit::ParseLanguage(text, &ignored)) { @@ -270,7 +280,7 @@ struct ParsedArguments { return parsed; } -/// `--list`:列出识别到的 RNDIS 设备。**不需要 root**。 +/// `--list`: print the recognized RNDIS devices. **Does not need root**. [[nodiscard]] Status ListDevices(const tetherkit::usb::DeviceFilter& filter) { TETHERKIT_ASSIGN_OR_RETURN(const auto context, tetherkit::usb::Context::Create()); TETHERKIT_ASSIGN_OR_RETURN(const auto candidates, @@ -297,8 +307,9 @@ struct ParsedArguments { } // namespace int main(int argc, char** argv) { - // 语言要在**任何**输出之前定下来。先按环境推断,再让显式的 --lang 覆盖 —— - // 这样连参数解析自己报的错也是用户要的那种语言。 + // The language must be settled **before any** output. Infer from the + // environment first, then let an explicit --lang override -- that way even + // argument parsing's own errors come out in the user's language. tetherkit::SetLanguage(tetherkit::DetectLanguageFromEnvironment()); ApplyLanguageOption(argc, argv); @@ -347,9 +358,10 @@ int main(int argc, char** argv) { return 1; } - // 信号处理器里只能置一个原子(异步信号安全的唯一做法),所以需要有人把它 - // 转达给运行时。用一个轻量的守望线程而不是让 Runtime 直接读进程级全局变量 —— - // 这样 Runtime 不依赖任何全局状态,便于测试。 + // A signal handler may only set one atomic (the only async-signal-safe + // option), so someone has to relay it to the runtime. A lightweight watcher + // thread rather than letting Runtime read the process-level global directly + // -- that way Runtime depends on no global state and stays testable. std::thread signal_watcher([&runtime] { while (!g_stop_requested.load(std::memory_order_acquire)) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); @@ -358,19 +370,21 @@ int main(int argc, char** argv) { (*runtime)->RequestStop(); }); - // 控制循环跑在 Runtime 自己的线程上(见 core/runtime.h 的线程模型说明), - // 主线程只需要在这里挂起等它结束。 + // The control loop runs on Runtime's own thread (see the thread model notes + // in core/runtime.h); the main thread only parks here until it ends. (*runtime)->WaitUntilStopped(); - // 控制循环可能是因为内部致命错误退出的(而不是收到信号), - // 这时也要让守望线程结束。 + // The control loop may have exited because of an internal fatal error + // rather than a signal; the watcher thread must still be released. g_stop_requested.store(true, std::memory_order_release); signal_watcher.join(); (*runtime)->Stop(); - // 启动序列是异步跑的,失败不会体现在 Start() 的返回值里 —— 从快照里取。 - // 错误内容运行时已经打过日志,这里只负责把退出码带出去,好让脚本能判断。 + // The start sequence runs asynchronously, so failures do not show up in + // Start()'s return value -- take them from the snapshot. The error text has + // already been logged; here we only carry the exit code out so scripts can + // tell. const auto snapshot = (*runtime)->Snapshot(); return snapshot.fatal_message.empty() ? 0 : 1; } diff --git a/src/capi/.clang-tidy b/src/capi/.clang-tidy index 9b9e61b..e9533b1 100644 --- a/src/capi/.clang-tidy +++ b/src/capi/.clang-tidy @@ -1,7 +1,8 @@ -# C ABI 实现层的检查豁免 +# Check exemptions for the C ABI implementation layer # -# 理由同 include/tetherkit/capi/.clang-tidy:导出的函数名必须是 C 惯例的 -# `tk_snake_case`,且实现里不可避免地要写 C 风格的定长缓冲拷贝与裸指针。 +# Same reasoning as include/tetherkit/capi/.clang-tidy: the exported function +# names must follow the C convention `tk_snake_case`, and the implementation +# unavoidably contains C-style fixed-length buffer copies and raw pointers. InheritParentConfig: true Checks: > diff --git a/src/capi/CMakeLists.txt b/src/capi/CMakeLists.txt index 49724cd..f62c8c5 100644 --- a/src/capi/CMakeLists.txt +++ b/src/capi/CMakeLists.txt @@ -1,12 +1,17 @@ -# tk_capi —— C ABI 层 +# tk_capi -- the C ABI layer # -# 全项目**唯一**的 extern "C" 边界,存在的唯一理由是给 Swift GUI 用。 -# 它站在依赖链的最顶端(和 app 同层),依赖 core 及其下的全部模块。 +# The **only** extern "C" boundary of the whole project; it exists for one +# reason: to serve the Swift GUI. +# It sits at the top of the dependency chain (same level as app) and depends +# on core and everything below it. # -# 编成 OBJECT 库而不是 STATIC,是为了让共享库 libtetherkit 能拿到**全部**目标 -# 文件。若编成静态库,链接器只会挑「有人引用的」成员,而 C ABI 的导出函数在 -# 库内部无人引用 —— 结果会是一个空的 dylib,且没有任何报错。 -# (另一条路是 -force_load,但 OBJECT 库是 CMake 里更直白的表达。) +# Built as an OBJECT library rather than a STATIC one, so that the shared +# library libtetherkit picks up **all** the object files. With a static +# library the linker only pulls in the members somebody references, and the +# exported C ABI functions are referenced by nobody inside the library -- the +# result is an empty dylib, with no error message whatsoever. +# (The other route is -force_load, but an OBJECT library is the more direct +# way to say it in CMake.) add_library( tk_capi OBJECT @@ -21,15 +26,18 @@ add_library( target_link_libraries(tk_capi PUBLIC tetherkit_base tk_version tk_core) -# 网卡 IP 配置要回读 configd 的动态存储(真实生效的网关与 DNS),只能走 -# SystemConfiguration。这是 tk_capi 独有的依赖 —— 下层模块一概不碰 configd。 +# The interface IP configuration has to read back configd's dynamic store +# (the gateway and DNS that are actually in effect), and that means going +# through SystemConfiguration. This is a dependency of tk_capi alone -- the +# lower layers never touch configd. target_link_libraries(tk_capi PUBLIC "-framework CoreFoundation" "-framework SystemConfiguration") # ------------------------------------------------------------------------------ -# libtetherkit —— 对外分发的共享库 +# libtetherkit -- the shared library we distribute # -# Swift 只能链接共享库或系统库,链接不了一堆 CMake 静态目标,所以必须有这一层。 +# Swift can only link shared libraries or system libraries, not a pile of CMake +# static targets, so this layer has to exist. # ------------------------------------------------------------------------------ if(TETHERKIT_BUILD_SHARED_LIB) add_library(tetherkit_shared SHARED) @@ -40,19 +48,22 @@ if(TETHERKIT_BUILD_SHARED_LIB) PROPERTIES OUTPUT_NAME tetherkit VERSION "${PROJECT_VERSION}" SOVERSION "${PROJECT_VERSION_MAJOR}" - # 让依赖方按 @rpath 加载,而不是烧死绝对路径 —— .app 内嵌 - # 这个 dylib 时才不用事后 install_name_tool 改(改完还得重签, - # 见 docs/GUI-SPIKE.md 第 7 节踩过的坑)。 + # Let the dependents load it by @rpath instead of baking in an + # absolute path -- otherwise the .app has to run install_name_tool + # on the embedded dylib afterwards (and re-sign it after that, + # see the pitfall documented in docs/GUI-SPIKE.md section 7). INSTALL_NAME_DIR "@rpath") - # 只导出 C ABI,其余符号(含 C++ 重整名)一律隐藏。理由见白名单文件。 + # Export only the C ABI; every other symbol (including the C++ mangled + # names) is hidden. The reason is documented in the allowlist file. target_link_options(tetherkit_shared PRIVATE "-Wl,-exported_symbols_list,${PROJECT_SOURCE_DIR}/cmake/tetherkit_exports.txt") install(TARGETS tetherkit_shared LIBRARY DESTINATION lib) endif() -# C ABI 头对外安装:Homebrew formula 装完之后,别的项目也能拿它做绑定。 +# The C ABI header is installed for the outside: once the Homebrew formula is +# installed, other projects can bind to it too. install(DIRECTORY "${PROJECT_SOURCE_DIR}/include/tetherkit/capi" DESTINATION include/tetherkit FILES_MATCHING diff --git a/src/capi/capi_support.cc b/src/capi/capi_support.cc index 027eb28..0ff3749 100644 --- a/src/capi/capi_support.cc +++ b/src/capi/capi_support.cc @@ -15,8 +15,10 @@ std::int64_t WallNanos() noexcept { void ReconcileDeviceStrings(std::vector& memory, std::span present, std::span infos) { - // 先淘汰已不在总线上的设备 —— 顺序重要:淘汰在回填之前,拔掉再插回 - // 同一地址的「另一台」设备才不会拿到前一台的名字。 + // Evict the devices that are no longer on the bus first -- the order + // matters: eviction runs before the back-fill, so a "different device" + // plugged back into the same address never picks up the previous one's + // name. std::erase_if(memory, [present](const RememberedDeviceStrings& entry) { return std::ranges::find(present, entry.identity) == present.end(); }); @@ -31,7 +33,8 @@ void ReconcileDeviceStrings(std::vector& memory, }); if (read_succeeded) { - // 读到了 → 覆盖式记住(同身份重插时序列号也跟着最新一次读取走)。 + // Read -> remember it (overwriting; when the same identity is plugged + // back in, the serial number follows the most recent read). RememberedDeviceStrings& slot = entry != memory.end() ? *entry @@ -40,8 +43,9 @@ void ReconcileDeviceStrings(std::vector& memory, CopyText(slot.product, info.product); CopyText(slot.serial, info.serial); } else if (entry != memory.end()) { - // 没读到但有记忆 → 回填。设备真的没提供字符串时记忆本来就不存在, - // 这里不会无中生有。 + // Not read but there is a memory -> back-fill. When the device really + // provides no strings there is no memory either, so nothing is made up + // here. CopyText(info.manufacturer, entry->manufacturer); CopyText(info.product, entry->product); CopyText(info.serial, entry->serial); @@ -51,7 +55,7 @@ void ReconcileDeviceStrings(std::vector& memory, bool IsValidFethName(std::string_view name) noexcept { constexpr std::string_view kPrefix = "feth"; - // 名字还要塞得进内核的 IFNAMSIZ 缓冲。 + // The name also has to fit into the kernel's IFNAMSIZ buffer. if (name.size() <= kPrefix.size() || name.size() >= TK_INTERFACE_NAME_CAPACITY) { return false; } diff --git a/src/capi/capi_support.h b/src/capi/capi_support.h index fc96d0d..a69b266 100644 --- a/src/capi/capi_support.h +++ b/src/capi/capi_support.h @@ -1,7 +1,9 @@ -// C ABI 实现层的内部工具。**不对外安装**,只在 src/capi 内部使用。 +// Internal helpers for the C ABI implementation. **Not installed**; used only +// inside src/capi. // -// 这里集中处理「C++ 世界 ↔ C 世界」的三件重复劳动:把 std::string_view 拷进 -// 定长缓冲、把 tetherkit::Error 翻译成 tk_error_t、取墙上时间。 +// The three recurring chores of the "C++ world <-> C world" bridge live here: +// copying a std::string_view into a fixed-size buffer, translating a +// tetherkit::Error into a tk_error_t, and reading the wall clock. #pragma once #include @@ -16,20 +18,25 @@ namespace tetherkit::capi { -/// 把文本拷进定长缓冲并保证 NUL 结尾,超长时截断。 +/// Copies text into a fixed-size buffer, guarantees NUL termination, and +/// truncates when it is too long. /// -/// ★ 截断必须落在 UTF-8 的字符边界上 ★ -/// 本项目的错误消息全是中文,一个汉字 3 字节。按字节硬切会在缓冲末尾留下 -/// 半个字符,Swift 侧 `String(cString:)` 遇到非法序列会**整串变成替换字符**, -/// 等于把整条错误信息毁掉。所以截断后要退回到最近的字符起点。 +/// The truncation must land on a UTF-8 character boundary +/// The user-facing strings of this project are not necessarily ASCII: one +/// CJK character takes 3 bytes. A hard byte cut leaves half a character at +/// the end of the buffer, and on the Swift side `String(cString:)` turns the +/// **whole string** into replacement characters when it meets an invalid +/// sequence -- which destroys the entire error message. So after truncating +/// we walk back to the nearest character start. inline void CopyText(char* destination, std::size_t capacity, std::string_view text) noexcept { if (destination == nullptr || capacity == 0) { return; } std::size_t length = text.size() < capacity - 1 ? text.size() : capacity - 1; - // length < text.size() 说明发生了截断:若切点落在了某个多字节序列的中间 - // (text[length] 是 0b10xxxxxx 的续接字节),就一路退到该序列的起点。 + // length < text.size() means truncation happened: if the cut point landed in + // the middle of a multibyte sequence (text[length] is a 0b10xxxxxx + // continuation byte), walk back to the start of that sequence. while (length > 0 && length < text.size() && (static_cast(text[length]) & 0xC0U) == 0x80U) { --length; @@ -39,25 +46,27 @@ inline void CopyText(char* destination, std::size_t capacity, std::string_view t destination[length] = '\0'; } -/// 数组版本,省掉每处都写 sizeof。 +/// Array version, to avoid writing sizeof at every call site. template inline void CopyText(char (&destination)[N], std::string_view text) noexcept { CopyText(destination, N, text); } -/// 把 C++ 侧的错误翻译成 C 结构体。`out_error` 可为 nullptr(调用方不关心原因)。 +/// Translates a C++-side error into the C struct. `out_error` may be nullptr +/// (the caller does not care about the reason). inline void FillError(tk_error_t* out_error, const Error& error) noexcept { if (out_error == nullptr) { return; } out_error->domain = static_cast(error.Domain()); out_error->code = error.Code(); - // 用 ToString() 而非 Context():前者带上了「[libusb: LIBUSB_ERROR_ACCESS(-3)]」 - // 这样的域与码,用户报错时能直接贴给我们。 + // ToString() rather than Context(): the former carries the domain and code + // (such as "[libusb: LIBUSB_ERROR_ACCESS(-3)]"), which the user can paste + // straight to us when reporting a problem. CopyText(out_error->message, error.ToString()); } -/// 填一条没有底层错误码的纯逻辑错误。 +/// Fills in a purely logical error that carries no underlying error code. inline void FillGenericError(tk_error_t* out_error, std::string_view message) noexcept { if (out_error == nullptr) { return; @@ -67,7 +76,8 @@ inline void FillGenericError(tk_error_t* out_error, std::string_view message) no CopyText(out_error->message, message); } -/// 把错误结构体清成「无错误」。成功路径上调用,避免调用方读到上一次的残留。 +/// Clears the error struct to "no error". Called on the success path so the +/// caller never reads a leftover from the previous call. inline void ClearError(tk_error_t* out_error) noexcept { if (out_error == nullptr) { return; @@ -77,10 +87,11 @@ inline void ClearError(tk_error_t* out_error) noexcept { out_error->message[0] = '\0'; } -/// 唯一确定「插在总线上的一台设备」的四元组。 +/// The tuple that uniquely identifies "a device plugged into the bus". /// -/// 总线地址在拔插后可能被系统复用,多带上 VID:PID 才不会把一台设备的记忆 -/// 错安到接替同一地址的另一台头上。 +/// A bus address can be reused by the system after an unplug, so adding +/// VID:PID is what keeps one device's memory from being attached to a +/// different device that took over the same address. struct DeviceIdentity { std::uint8_t bus_number = 0; std::uint8_t device_address = 0; @@ -90,7 +101,7 @@ struct DeviceIdentity { [[nodiscard]] bool operator==(const DeviceIdentity&) const noexcept = default; }; -/// 从枚举结果里取设备身份。 +/// Extracts the device identity from an enumeration result. [[nodiscard]] inline DeviceIdentity IdentityOf(const tk_device_info_t& info) noexcept { return DeviceIdentity{.bus_number = info.bus_number, .device_address = info.device_address, @@ -98,7 +109,7 @@ struct DeviceIdentity { .product_id = info.product_id}; } -/// 一台设备最近一次**成功读到**的字符串描述符。 +/// The string descriptors **successfully read** from a device most recently. struct RememberedDeviceStrings { DeviceIdentity identity; char manufacturer[TK_USB_STRING_CAPACITY]{}; @@ -106,46 +117,63 @@ struct RememberedDeviceStrings { char serial[TK_USB_STRING_CAPACITY]{}; }; -/// 维护字符串描述符的记忆:读到了就记住,没读到就用记忆回填。 +/// Maintains the memory of string descriptors: remember what was read, and +/// back-fill from memory what could not be read. /// -/// ★ 为什么需要 ★ -/// 字符串描述符要 libusb_open 才能读,而会话运行期间设备被本进程独占, -/// 读取会被调用方跳过或直接失败 —— 但设备本身没变,「这一次拿不到」不等于 -/// 「设备没有名字」。不回填的话,连接瞬间的一次枚举就会把界面上的 -/// 「vivo iQOO Z10x」覆盖成「USB 设备 2d95:600b」,并在整个会话期间挂着。 +/// Why it is needed +/// A string descriptor can only be read after libusb_open, and while a +/// session is running the device is held exclusively by this process, so +/// the read is either skipped by the caller or fails outright -- but the +/// device itself has not changed, and "not available this time" does not +/// mean "the device has no name". Without the back-fill, one enumeration at +/// the moment of connection overwrites the "vivo iQOO Z10x" in the UI with +/// "USB device 2d95:600b", and that stays there for the whole session. /// -/// 规则(`infos` 是本次枚举已填好基础字段的设备): -/// - `infos[i]` 三个字符串**有任何一个非空** → 这次读到了,覆盖式记入 `memory`; -/// - 全空 → 没读到(被跳过或设备被占用),若 `memory` 里有同身份的记忆则回填; -/// - `memory` 里身份不在 `present`(当前总线上的全部设备)中的条目被清除 —— -/// 设备已经拔掉,记忆过期;同一地址若再插上一台读不出名字的设备, -/// 宁可显示 VID:PID 也不能把前一台的名字安给它。 +/// The rules (`infos` are the devices of this enumeration whose basic fields +/// are already filled in): +/// - **any** of the three strings of `infos[i]` is non-empty -> it was read +/// this time, record it into `memory` overwriting whatever was there; +/// - all empty -> it was not read (skipped, or the device was busy); if +/// `memory` holds an entry with the same identity, back-fill from it; +/// - entries in `memory` whose identity is not in `present` (all devices +/// currently on the bus) are dropped -- the device has been unplugged and +/// the memory is stale; if another device that yields no name is plugged +/// into the same address, showing VID:PID is better than handing it the +/// previous device's name. /// -/// `present` 可能比 `infos` 长:调用方给的数组容量不够时只填了前几台, -/// 后面那些仍然在场,它们的记忆不该被误删。 -/// 纯逻辑、不碰 libusb,锁由调用方负责 —— 这样才能离线单测。 +/// `present` can be longer than `infos`: when the caller's array capacity is +/// too small only the first few are filled in, and the ones after that are +/// still present -- their memory must not be mistaken for "unplugged". +/// Pure logic, does not touch libusb, and the lock is the caller's +/// responsibility -- that is what makes it testable offline. void ReconcileDeviceStrings(std::vector& memory, std::span present, std::span infos); -/// 自 Unix 纪元的墙上时间(纳秒)。 +/// Wall clock time since the Unix epoch (nanoseconds). /// -/// 事件与日志用墙上时间而非单调时间:它们要显示给人看,并且要能和 Console.app -/// 里的系统日志对齐。速率计算另有单调时间,见 tk_session_status.monotonic_nanos。 +/// Events and logs use the wall clock rather than the monotonic clock: they +/// are shown to people and have to line up with the system log in Console.app. +/// Rate calculation uses the monotonic clock instead; see +/// tk_session_status.monotonic_nanos. [[nodiscard]] std::int64_t WallNanos() noexcept; -/// 校验网卡名:只接受 `feth<数字>`。 +/// Validates the interface name: only `feth` is accepted. /// -/// 这不是洁癖 —— 网卡名会被拼进 `ipconfig` / `route` 的 argv。虽然我们用 -/// posix_spawn 传数组而非过 shell(本身已经杜绝了注入),但把接口名限制成 -/// 我们自己创建过的那种形态,还能挡住「误把 en0 传进来,把用户的 Wi-Fi 配置 -/// 冲掉」这类更现实的事故。 +/// This is not fussiness -- the interface name ends up in the argv of +/// `ipconfig` / `route`. We do pass an array to posix_spawn instead of going +/// through a shell (which already rules out injection), but restricting the +/// interface name to the shape we ourselves create also stops the more +/// realistic accidents, such as "en0 got passed in and the user's Wi-Fi +/// configuration got wiped". [[nodiscard]] bool IsValidFethName(std::string_view name) noexcept; -/// 安装 feth 接口的落盘登记(见 orphan_cleanup.cc)。幂等。 +/// Installs the on-disk registry of feth interfaces (see orphan_cleanup.cc). +/// Idempotent. /// -/// 由 tk_session_create 调用 —— 只有真的要创建网卡时才需要登记,让免 root 的 -/// 那组接口(版本、枚举、预检)保持零副作用。 +/// Called by tk_session_create -- only a session that really creates an +/// interface needs to register, which keeps the root-free group of entry +/// points (version, enumeration, preflight) free of side effects. void InstallInterfaceRegistry(); } // namespace tetherkit::capi diff --git a/src/capi/core_foundation_support.cc b/src/capi/core_foundation_support.cc index 1c950b9..aaf6ccf 100644 --- a/src/capi/core_foundation_support.cc +++ b/src/capi/core_foundation_support.cc @@ -16,8 +16,10 @@ std::string CopyToStdString(CFStringRef text) { return {}; } - // 先试零拷贝的快路径:CFStringGetCStringPtr 在内部本来就是 UTF-8 时直接给 - // 指针,绝大多数情况(我们读的都是网卡名、IP 这类 ASCII)都能命中。 + // First try the zero-copy fast path: CFStringGetCStringPtr hands back the + // pointer directly when the string is already UTF-8 internally, which hits + // in the vast majority of cases (what we read are things like interface + // names and IPs, i.e. ASCII). if (const char* direct = ::CFStringGetCStringPtr(text, kCFStringEncodingUTF8); direct != nullptr) { return std::string{direct}; @@ -33,10 +35,12 @@ std::string CopyToStdString(CFStringRef text) { } SCDynamicStoreRef SharedDynamicStore() { - // 函数内静态量:首次调用时创建,进程退出前不释放。理由见头文件。 + // A function-local static: created on the first call, never released before + // the process exits. See the header for the reason. // - // 不加锁是安全的 —— C++11 起,函数内静态量的初始化由编译器保证线程安全 - // (magic static),并发首次调用只会有一个线程真正执行初始化。 + // No lock is needed -- since C++11 the initialization of a function-local + // static is thread-safe (the magic static), so concurrent first calls let + // only one thread actually run the initialization. static SCDynamicStoreRef store = ::SCDynamicStoreCreate( kCFAllocatorDefault, CFSTR("TetherKit"), /*callout=*/nullptr, /*context=*/nullptr); return store; diff --git a/src/capi/core_foundation_support.h b/src/capi/core_foundation_support.h index bc5a324..73461f9 100644 --- a/src/capi/core_foundation_support.h +++ b/src/capi/core_foundation_support.h @@ -1,8 +1,9 @@ -// CoreFoundation / SystemConfiguration 的最小 RAII 封装。 +// A minimal RAII wrapper for CoreFoundation / SystemConfiguration. // -// 只封到「够用」为止:项目里唯一需要碰 CF 的地方是读写 SCDynamicStore, -// 引入一个完整的 CF 智能指针库不划算,但手写 CFRelease 又太容易在提前 return -// 的分支上漏掉。 +// Wrapped only as far as "enough to get by": the one place in this project +// that touches CF is reading and writing the SCDynamicStore, pulling in a +// complete CF smart-pointer library is not worth it, and hand-written +// CFRelease calls are too easy to forget on an early-return branch. #pragma once #include @@ -14,10 +15,11 @@ namespace tetherkit::capi { -/// 持有一个 CF 对象的所有权,析构时 CFRelease。 +/// Owns a CF object and CFReleases it on destruction. /// -/// 只用于 Create/Copy 规则下**我们拥有**的引用。CFDictionaryGetValue 这类 -/// Get 规则返回的引用不拥有所有权,绝不能塞进来 —— 会造成过度释放。 +/// Only for references we **own** under the Create/Copy rules. A reference +/// returned by a Get rule such as CFDictionaryGetValue is not owned and must +/// never be put in here -- that would cause an over-release. template class ScopedCFRef { public: @@ -55,23 +57,26 @@ class ScopedCFRef { T reference_ = nullptr; }; -/// UTF-8 字符串 → CFString。 +/// UTF-8 string -> CFString. [[nodiscard]] ScopedCFRef MakeCFString(std::string_view text); -/// CFString → UTF-8 std::string。传 nullptr 得到空串。 +/// CFString -> UTF-8 std::string. Passing nullptr yields an empty string. [[nodiscard]] std::string CopyToStdString(CFStringRef text); -/// 进程级共享的 SCDynamicStore 句柄。首次调用时创建,之后一直复用;失败返回 -/// nullptr。 +/// The process-wide shared SCDynamicStore handle. Created on the first call +/// and reused from then on; returns nullptr on failure. /// -/// ★ 为什么必须是进程级长命的 ★ -/// SCDynamicStore 里由某个会话**设置**的值,会在那个会话释放时被一并删除。 -/// 我们要往动态存储写 DNS 键,如果用临时创建、用完就 release 的 store, -/// 刚写进去的值转眼就没了 —— 而且现象是「写入返回成功、读回来是空」, -/// 非常难查。 +/// Why it must be a process-lifetime handle +/// A value **set** in the SCDynamicStore by a session is deleted along with +/// that session when it is released. We have to write DNS keys into the +/// dynamic store, and with a store that is created temporarily and released +/// right after use the value we just wrote is gone a moment later -- and +/// the symptom is "the write returns success, the read-back comes back +/// empty", which is very hard to track down. /// -/// 代价是这个句柄直到进程退出都不释放,这正是我们要的:helper 退出时 -/// 由它写的动态存储条目自动清理,不留垃圾。 +/// The price is that the handle is never released before the process exits, +/// and that is exactly what we want: the dynamic store entries written by +/// the helper are cleaned up automatically when it exits, leaving no litter. [[nodiscard]] SCDynamicStoreRef SharedDynamicStore(); } // namespace tetherkit::capi diff --git a/src/capi/environment.cc b/src/capi/environment.cc index 2d1b399..630f6cb 100644 --- a/src/capi/environment.cc +++ b/src/capi/environment.cc @@ -1,7 +1,9 @@ -// 免 root 的三件事:版本、环境预检、设备枚举。 +// The three root-free capabilities: version, environment preflight, device +// enumeration. // -// GUI 本体(uid 501)只调这一组;需要 root 的会话与网卡配置全部交给 -// tetherkit-helper。这个划分是 docs/GUI-SPIKE.md 的核心结论之一。 +// The GUI itself (uid 501) calls only this group; the session and the +// interface configuration that need root are handed to tetherkit-helper. This +// split is one of the core conclusions of docs/GUI-SPIKE.md. #include #include @@ -29,11 +31,14 @@ using tetherkit::capi::ClearError; using tetherkit::capi::CopyText; using tetherkit::capi::FillError; -/// 读一个 USB 字符串描述符到定长缓冲。索引为 0 表示设备没提供该字符串。 +/// Reads a USB string descriptor into a fixed-size buffer. An index of 0 means +/// the device provides no such string. /// -/// 用 libusb 的 `_ascii` 变体:它把 UTF-16LE 里超出 ASCII 的码位替换成 '?'。 -/// 对我们够用 —— 这些字符串只用于让用户区分两台同型号设备,绝大多数厂商名与 -/// 序列号本来就是 ASCII。自己做完整的 UTF-16 → UTF-8 转换收益太小。 +/// Uses libusb's `_ascii` variant: it replaces code points beyond ASCII in the +/// UTF-16LE with '?'. That is good enough for us -- these strings only let the +/// user tell two devices of the same model apart, and most manufacturer names +/// and serial numbers are ASCII to begin with. A full UTF-16 -> UTF-8 +/// conversion of our own would gain too little to be worth it. void ReadStringDescriptor(::libusb_device_handle* handle, std::uint8_t index, char* destination, std::size_t capacity) noexcept { if (index == 0) { @@ -50,13 +55,16 @@ void ReadStringDescriptor(::libusb_device_handle* handle, std::uint8_t index, ch static_cast(length)}); } -/// 尽力而为地补上厂商名 / 产品名 / 序列号。 +/// Best-effort fill-in of the manufacturer name / product name / serial number. /// -/// 为什么是「尽力而为」:读字符串描述符必须先 libusb_open,而设备可能已被本机 -/// 另一个进程(比如正在跑的 tetherkit-helper)独占,darwin 后端会返回 -/// LIBUSB_ERROR_ACCESS。那不是错误,只是拿不到名字 —— 此时由字符串记忆回填 -/// 上次读到的值(见 tk_list_devices 末尾),连记忆都没有才回落到 VID:PID。 -/// 绝不能因此让整个枚举失败。 +/// Why "best effort": reading a string descriptor requires libusb_open first, +/// and the device may already be held exclusively by another process on this +/// machine (a running tetherkit-helper, say), in which case the darwin backend +/// returns LIBUSB_ERROR_ACCESS. That is not an error, it is just an +/// unavailable name -- at that point the string memory back-fills the value +/// read last time (see the end of tk_list_devices), and only when there is no +/// memory either does it fall back to VID:PID. It must never make the whole +/// enumeration fail. void TryReadStrings(::libusb_context* context, tk_device_info_t& info) noexcept { ::libusb_device** list = nullptr; const ssize_t count = ::libusb_get_device_list(context, &list); @@ -86,25 +94,32 @@ void TryReadStrings(::libusb_context* context, tk_device_info_t& info) noexcept break; } - // 第二个参数为 1:连同 list 里每个设备的引用计数一起释放。 + // The second argument is 1: release the list together with the reference + // count of every device in it. ::libusb_free_device_list(list, 1); } -/// 进程级共享的 libusb 上下文,**仅供枚举使用**。 +/// The process-wide shared libusb context, **for enumeration only**. /// -/// ★ 为什么必须共享 ★ -/// 每次枚举都新建一个上下文,等于每次都走一遍「初始化 libusb → 起事件线程 -/// → 起 IOKit runloop 线程 → 用完全部拆掉」。GUI 每几百毫秒刷新一次设备 -/// 列表,这套开销就以同样的频率重复,日志里也会被「libusb 已初始化」刷满。 +/// Why it must be shared +/// Creating a new context for every enumeration means running the whole +/// "initialize libusb -> start the event thread -> start the IOKit runloop +/// thread -> tear everything down" sequence every time. The GUI refreshes +/// the device list every few hundred milliseconds, so that overhead repeats +/// at the same rate, and the log fills up with "libusb initialized". /// -/// 共享之后整个进程只初始化一次。长命上下文照样能看到新插上的设备 —— -/// libusb 的 darwin 后端有自己的热插拔线程在维护设备列表,这正是所有 -/// libusb 程序依赖的常规机制。 +/// Sharing it means the whole process initializes once. A long-lived context +/// still sees newly plugged devices -- libusb's darwin backend has its own +/// hotplug thread maintaining the device list, which is the regular +/// mechanism every libusb program depends on. /// -/// 会话(core::Runtime)另有自己的上下文,不复用这一个:它的生命周期由会话 -/// 自己管,混进来只会让停机顺序变复杂。libusb 支持同进程多上下文。 +/// The session (core::Runtime) has a context of its own and does not reuse +/// this one: its lifetime is managed by the session, and mixing it in would +/// only complicate the shutdown ordering. libusb supports multiple contexts +/// per process. /// -/// 初始化失败时返回 nullptr 并填错误,且**不缓存失败**,下次调用会重试。 +/// On initialization failure this returns nullptr and fills in the error, and +/// it does **not** cache the failure -- the next call retries. tetherkit::usb::Context* SharedEnumerationContext(tk_error_t* out_error) { static std::mutex mutex; static std::unique_ptr context; @@ -121,10 +136,13 @@ tetherkit::usb::Context* SharedEnumerationContext(tk_error_t* out_error) { return context.get(); } -/// 字符串描述符的进程级记忆(锁 + 条目),与共享枚举上下文同款的函数局部 static。 +/// The process-wide memory of string descriptors (lock + entries), the same +/// function-local static pattern as the shared enumeration context. /// -/// 放在进程级而不是调用方:helper 是常驻进程,GUI 中途重启后第一次枚举 -/// 就能拿到回填的名字;纯逻辑见 ReconcileDeviceStrings 的说明。 +/// It lives at process level rather than in the caller: the helper is a +/// long-lived process, so the very first enumeration after the GUI restarts +/// already gets the back-filled names. The pure logic is described in +/// ReconcileDeviceStrings. struct StringMemory { std::mutex mutex; std::vector entries; @@ -155,7 +173,7 @@ void FillDeviceInfo(const tetherkit::usb::DeviceCandidate& candidate, namespace { -/// tk_language_t → C++ Language. Unknown values return nullopt, which makes +/// tk_language_t > C++ Language. Unknown values return nullopt, which makes /// the set call a no-op: a caller still passing a removed language id (the /// old Chinese value 1) keeps working and simply stays in English. [[nodiscard]] std::optional ToLanguage(std::int32_t value) noexcept { @@ -212,9 +230,10 @@ tk_result_t tk_check_environment(tk_environment_t* out_environment) { out_environment->is_root = tetherkit::net::IsRunningAsRoot(); - // 这批 sysctl 是 feth 的**创建期快照**,创建后再改无效,所以必须提前查。 - // 不合格时把原因原样交给 GUI 展示 —— 用户看到具体是哪个开关被打开了, - // 才知道该改什么。 + // These sysctls are a **creation-time snapshot** of feth; changing them + // after the interface exists has no effect, so they must be queried up + // front. When they do not qualify, the reason is handed to the GUI as-is -- + // the user has to see which switch is turned on to know what to change. if (const auto status = tetherkit::net::VerifyFethSysctls(); status) { out_environment->sysctls_ok = true; } else { @@ -226,7 +245,8 @@ tk_result_t tk_check_environment(tk_environment_t* out_environment) { out_environment->feth_max_mtu = *max_mtu; } - // 刻意永远返回成功:「环境不合格」是要展示给用户的**结果**,不是调用失败。 + // Deliberately always returns success: "the environment does not qualify" is + // a **result** to be shown to the user, not a call failure. return TK_OK; } @@ -262,9 +282,11 @@ tk_result_t tk_list_devices(tk_device_info_t* out_devices, size_t capacity, size } } - // 读到就记住、没读到就回填 —— 会话运行期间(read_strings=false 或设备被占用) - // 名字才不会从界面上消失。present 用完整候选集而非 writable 前缀: - // 容量不够时后面那些设备仍然在场,它们的记忆不能被当作「已拔掉」清除。 + // Remember what was read, back-fill what was not -- so that while a session + // is running (read_strings=false, or the device is busy) the name does not + // vanish from the UI. `present` uses the full candidate set rather than the + // writable prefix: when the capacity is too small the devices after that + // are still present, and their memory must not be dropped as "unplugged". { std::vector present; present.reserve(candidates->size()); diff --git a/src/capi/log_ring.cc b/src/capi/log_ring.cc index 33929c5..40b8a74 100644 --- a/src/capi/log_ring.cc +++ b/src/capi/log_ring.cc @@ -1,9 +1,11 @@ -// 日志捕获:把库内部的日志行缓冲起来,供 GUI 轮询取走。 +// Log capture: buffers the library's internal log lines for the GUI to poll. // -// 为什么不用回调直通 Swift:日志会从 libusb 事件线程、控制线程、两条数据路径 -// 线程上来,而且是在日志互斥锁**内部**产生的。让 Swift 闭包在那个上下文里跑, -// 既要跨线程 marshal,又有「闭包里不小心又打了一条日志 → 自等死锁」的雷。 -// 缓冲 + 轮询把这两个问题一次性消掉。 +// Why not a callback straight into Swift: the logs arrive from the libusb +// event thread, the control thread and the two data path threads, and they +// are produced **inside** the log mutex. Running a Swift closure in that +// context means both marshalling across threads and the landmine of "the +// closure logs one more line -> self-deadlock". Buffering plus polling kills +// both problems at once. #include #include #include @@ -19,21 +21,24 @@ namespace { using tetherkit::capi::CopyText; using tetherkit::capi::WallNanos; -/// 环形缓冲容量。 +/// Capacity of the ring buffer. /// -/// 取 256 的依据:GUI 每 500 ms 拉一次,而库在稳态下每 5 秒才打一行统计。 -/// 256 条足以扛住启动序列那一波密集日志(约 30 行)与任何突发告警, -/// 同时把常驻内存控制在 256 × 552 B ≈ 138 KiB。 +/// Why 256: the GUI pulls every 500 ms, while the library logs one statistics +/// line every 5 seconds in steady state. 256 entries are enough to absorb the +/// dense burst of the startup sequence (about 30 lines) and any sudden flood +/// of warnings, while keeping the resident memory at 256 x 552 B ~ 138 KiB. constexpr std::size_t kLogRingCapacity = 256; -/// 定长环形缓冲。写满时丢**最旧**的 —— 宿主卡住时,最新的现场比开头有用。 +/// Fixed-size ring buffer. When it fills, the **oldest** entry is dropped -- +/// when the host is stuck, the newest scene is more useful than the beginning. class LogRing { public: void Push(tk_log_level_t level, std::string_view thread_name, std::string_view message) noexcept { const std::lock_guard guard(mutex_); if (count_ == kLogRingCapacity) { - // 覆盖最旧的一条:读指针前移,逻辑上等于把它丢掉。 + // Overwrite the oldest entry: advance the read pointer, which is + // logically the same as dropping it. head_ = (head_ + 1) % kLogRingCapacity; --count_; ++dropped_; @@ -75,23 +80,25 @@ class LogRing { private: std::mutex mutex_; std::array records_{}; - /// 下一条待读记录的下标。 + /// Index of the next record to read. std::size_t head_ = 0; std::size_t count_ = 0; std::uint64_t dropped_ = 0; }; -/// 进程级单例。用函数内静态量而非全局对象,避免静态初始化顺序问题 —— -/// 日志汇可能在任何翻译单元的静态构造期间就被触发。 +/// A process-wide singleton. A function-local static rather than a global +/// object, to avoid static initialization order problems -- the log sink may +/// be triggered during the static construction of any translation unit. LogRing& Ring() noexcept { static LogRing ring; return ring; } -/// 安装给 tetherkit::SetLogSink 的回调。 +/// Installs the callback for tetherkit::SetLogSink. /// -/// 它运行在日志互斥锁内部,因此实现里只允许「拷贝 + 加一把自己的锁」, -/// 绝不能再打日志(会自等死锁)。 +/// It runs inside the log mutex, so the implementation may only "copy plus +/// take one lock of its own", and must never log again (that would deadlock +/// on itself). void SinkTrampoline(tetherkit::LogLevel level, std::string_view thread_name, std::string_view message, void* /*user*/) noexcept { Ring().Push(static_cast(level), thread_name, message); @@ -112,13 +119,17 @@ void tk_enable_log_capture(bool enabled) { return; } - // 顺序很重要:**先**摘掉日志汇,**再**清空缓冲。 + // The order matters: **first** remove the log sink, **then** clear the + // buffer. // - // 反过来的话,摘除之前正在打日志的线程会把新记录塞进刚清空的缓冲里, - // 关闭捕获后反而留着几条残留。 + // The other way round, a thread that was logging before the removal pushes + // a new record into the just-cleared buffer, and a few leftovers survive + // the disabling of the capture. // - // 另外,这里刻意不持有 LogRing 的锁去调 SetLogSink —— SetLogSink 要拿日志 - // 互斥锁,而日志汇路径是「日志锁 → LogRing 锁」,反向持锁会构成锁序倒置。 + // Also, this deliberately does not hold the LogRing lock while calling + // SetLogSink -- SetLogSink takes the log mutex, and the log sink path is + // "log lock -> LogRing lock", so taking them in the reverse order would + // invert the lock order. tetherkit::SetLogSink(nullptr, nullptr); Ring().Clear(); } diff --git a/src/capi/net_config.cc b/src/capi/net_config.cc index 4ef7f91..e957a36 100644 --- a/src/capi/net_config.cc +++ b/src/capi/net_config.cc @@ -1,23 +1,32 @@ -// 虚拟网卡的上网方式配置:DHCP / 静态 IP / 撤销,以及真实生效状态的回读。 +// Network access mode configuration for the virtual interface: DHCP / static +// IP / clearing, plus a read-back of the state that is actually in effect. // -// ★ 为什么走 `ipconfig` 而不是自己 SIOCAIFADDR ★ +// Why `ipconfig` instead of issuing SIOCAIFADDR ourselves // -// 裸 ioctl 配出来的地址内核是认的(docs/GUI-SPIKE.md 第 6.1 节实测),但 -// **configd 完全不认** —— 不会有服务、不会有 scoped DNS、不会有默认路由。 -// 而 `ipconfig set` 走的是 IPConfiguration 的正规注册路径,由它建立的服务 -// IPMonitor 完全采纳。同一批动态存储键,手工写不认、IPConfiguration 写就认, -// 差别只在服务是否经正规路径注册(第 3.2 / 6.3 节)。 +// Addresses configured with a bare ioctl are honored by the kernel (measured, +// docs/GUI-SPIKE.md section 6.1), but **configd does not recognize them at +// all** -- no service, no scoped DNS, no default route. +// And `ipconfig set` goes through IPConfiguration's proper registration +// path; the service it creates is adopted wholesale by IPMonitor. The very +// same dynamic-store keys are ignored when written by hand and honored when +// written through IPConfiguration -- the difference is only whether the +// service was registered through the proper path (sections 3.2 / 6.3). // -// 代价:`ipconfig set` 建立的是**临时服务**,只活到下一次网络配置变更,且 -// 不出现在「系统设置 → 网络」里。对 GUI 反而好办 —— App 自己就是配置入口, -// 服务掉了重新 set 即可。 +// The cost: `ipconfig set` creates a **temporary service** that lives only +// until the next network configuration change, and it does not appear in +// "System Settings -> Network". For the GUI that is convenient anyway -- +// the App is the configuration entry point itself, and when the service +// drops it simply sets it again. // -// ★ 静态 IP 的 DNS 是「尽力而为 + 回读验证」★ +// Static-IP DNS is "best effort + verified by read-back" // -// IPConfiguration 只在 DHCP 模式下发布 DNS(那是从 DHCP 选项里来的)。 -// MANUAL 模式没有 DNS 来源,我们只能往它建立的那个服务上补一个 DNS 键。 -// 这一手**未经真机验证**,所以绝不向上层承诺成功:tk_net_query 一律回读 -// 系统里真实生效的解析器,GUI 显示的是回读结果而不是我们下发的值。 +// IPConfiguration publishes DNS only in DHCP mode (it comes from the DHCP +// options). MANUAL mode has no DNS source, so all we can do is add a DNS +// key to the service it created. +// That move is **not verified on a real device**, so we never promise +// success upward: tk_net_query always reads back the resolver that is +// actually in effect in the system, and the GUI shows the read-back result, +// not the value we pushed down. #include #include #include @@ -66,15 +75,17 @@ using tetherkit::capi::SharedDynamicStore; constexpr std::string_view kIpconfigPath = "/usr/sbin/ipconfig"; constexpr std::string_view kRoutePath = "/sbin/route"; -/// DHCP 租约的等待上限。 +/// Upper bound on the DHCP lease wait. /// -/// 取 10 秒的依据:DHCP 的 DISCOVER 重传是指数退避(1/2/4/8 秒),10 秒足够覆盖 -/// 前三次重传。再长就该告诉用户「对面大概没有 DHCP 服务器」而不是继续转圈。 +/// Why 10 seconds: the DHCP DISCOVER retransmission uses exponential backoff +/// (1/2/4/8 seconds), so 10 seconds covers the first three retransmissions. +/// Beyond that we should tell the user "there is probably no DHCP server on +/// the other end" instead of spinning forever. constexpr std::chrono::seconds kDhcpLeaseTimeout{10}; constexpr std::chrono::milliseconds kDhcpPollInterval{250}; // --------------------------------------------------------------------------- -// 参数校验 +// Argument validation // --------------------------------------------------------------------------- [[nodiscard]] bool IsValidIpv4(std::string_view text) noexcept { @@ -86,7 +97,7 @@ constexpr std::chrono::milliseconds kDhcpPollInterval{250}; return ::inet_pton(AF_INET, owned.c_str(), &parsed) == 1; } -/// 校验接口名并翻译成人话错误。 +/// Validates the interface name and turns a rejection into a plain error. [[nodiscard]] Status ValidateInterface(const char* interface_name) { if (interface_name == nullptr) { return std::unexpected(Error::Generic(Tr(Msg::kCapiInterfaceNameNull))); @@ -98,20 +109,23 @@ constexpr std::chrono::milliseconds kDhcpPollInterval{250}; } // --------------------------------------------------------------------------- -// 外部工具调用 +// External tool invocation // --------------------------------------------------------------------------- -/// 跑一条工具命令,非零退出即视为失败并把它的输出原样带上。 +/// Runs one tool command; a non-zero exit is treated as failure and its +/// output is carried along verbatim. /// -/// 原样带上很重要:ipconfig / route 的报错本身就是最准确的诊断信息, -/// 我们二次转述只会丢信息。 +/// Verbatim matters: the error messages of ipconfig / route are themselves the +/// most accurate diagnostic information, and paraphrasing them only loses +/// detail. [[nodiscard]] Status RunOrFail(std::string_view executable, const std::vector& arguments, std::string_view what) { TETHERKIT_ASSIGN_OR_RETURN(const ProcessResult result, RunTool(executable, arguments)); if (!result.Succeeded()) { std::string detail{result.output}; - // 工具的输出常带尾随换行,拼进一行错误里很难看。 + // Tool output usually carries a trailing newline; that reads badly when + // spliced into a one-line error. while (!detail.empty() && (detail.back() == '\n' || detail.back() == '\r')) { detail.pop_back(); } @@ -122,10 +136,11 @@ constexpr std::chrono::milliseconds kDhcpPollInterval{250}; return tetherkit::Ok(); } -/// 读接口当前的 IPv4 地址;没有地址时返回 std::nullopt。 +/// Reads the interface's current IPv4 address; returns std::nullopt when there +/// is none. /// -/// 用 ioctl 而不是 `ipconfig getifaddr`:这是内核里的真实状态,不经过任何 -/// 中间层,而且不用 fork 一个进程。 +/// An ioctl rather than `ipconfig getifaddr`: this is the real kernel state, +/// it goes through no intermediate layer, and it does not fork a process. [[nodiscard]] std::optional QueryAddress(std::string_view interface_name, unsigned long request) noexcept { const int fd = ::socket(AF_INET, SOCK_DGRAM, 0); @@ -152,14 +167,16 @@ constexpr std::chrono::milliseconds kDhcpPollInterval{250}; } // --------------------------------------------------------------------------- -// SCDynamicStore 查询 +// SCDynamicStore queries // --------------------------------------------------------------------------- -/// 找到 IPConfiguration 为该接口建立的服务 ID。 +/// Finds the service ID that IPConfiguration created for this interface. /// -/// 做法是枚举 `State:/Network/Service//IPv4` 并按 `InterfaceName` 匹配, -/// 而**不是**去猜服务 ID 的拼法。实测 `ipconfig set feth0 DHCP` 得到的 ID 是 -/// "DHCP-feth0",看起来可以直接拼,但那是实现细节,按字段匹配才靠得住。 +/// The approach is to enumerate `State:/Network/Service//IPv4` and match +/// on `InterfaceName`, **not** to guess how a service ID is spelled. The +/// measured ID for `ipconfig set feth0 DHCP` is "DHCP-feth0", which looks +/// concatenable, but that is an implementation detail; matching on the field +/// is what holds up. [[nodiscard]] std::optional FindServiceId(SCDynamicStoreRef store, std::string_view interface_name) { const ScopedCFRef pattern = MakeCFString("State:/Network/Service/[^/]+/IPv4"); @@ -183,7 +200,7 @@ constexpr std::chrono::milliseconds kDhcpPollInterval{250}; continue; } - // 从 "State:/Network/Service//IPv4" 里截出 。 + // Extracts from "State:/Network/Service//IPv4". const std::string full = CopyToStdString(key); constexpr std::string_view kPrefix = "State:/Network/Service/"; constexpr std::string_view kSuffix = "/IPv4"; @@ -204,7 +221,8 @@ constexpr std::chrono::milliseconds kDhcpPollInterval{250}; static_cast(::SCDynamicStoreCopyValue(store, key.Get()))}; } -/// 取字典里的字符串字段;缺失时返回空串。 +/// Reads a string field from the dictionary; returns an empty string when the +/// field is missing. [[nodiscard]] std::string StringField(CFDictionaryRef dictionary, CFStringRef field) { if (dictionary == nullptr) { return {}; @@ -216,7 +234,7 @@ constexpr std::chrono::milliseconds kDhcpPollInterval{250}; return CopyToStdString(value); } -/// 取字典里的字符串数组字段。 +/// Reads a string-array field from the dictionary. [[nodiscard]] std::vector StringArrayField(CFDictionaryRef dictionary, CFStringRef field) { std::vector values; @@ -238,7 +256,7 @@ constexpr std::chrono::milliseconds kDhcpPollInterval{250}; return values; } -/// 全局默认路由当前指向哪个接口。 +/// Which interface the global default route currently points at. [[nodiscard]] std::string PrimaryInterface(SCDynamicStoreRef store) { const ScopedCFRef key = MakeCFString("State:/Network/Global/IPv4"); const ScopedCFRef global{ @@ -247,19 +265,23 @@ constexpr std::chrono::milliseconds kDhcpPollInterval{250}; } // --------------------------------------------------------------------------- -// 下发 +// Applying // --------------------------------------------------------------------------- -/// 把 DNS 服务器写到 IPConfiguration 建立的那个服务上。 +/// Writes the DNS servers onto the service IPConfiguration created. /// -/// ⚠️ **未经真机验证的一手。** 已确认的是:手工**捏造**整个服务不会被 IPMonitor -/// 采纳(GUI-SPIKE 第 3.2 节)。这里的赌注是「往一个**已经正规注册**的服务上 -/// 补 DNS 键会被采纳」。赌错了也不会有破坏,只是 DNS 不生效 —— 所以失败一律 -/// 只记日志,绝不让整个静态 IP 配置失败。真实结果由 tk_net_query 回读汇报。 +/// **A move that is not verified on a real device.** What is confirmed: +/// hand-fabricating an entire service is not adopted by IPMonitor (GUI-SPIKE +/// section 3.2). The bet here is that "adding a DNS key to an **already +/// properly registered** service gets adopted". A wrong bet causes no damage +/// -- the DNS simply does not take effect -- so a failure is only logged and +/// never fails the whole static-IP configuration. The real result is reported +/// by the tk_net_query read-back. /// -/// 另外注意:SCDynamicStore 里的值**由设置它的会话持有**,会话一释放值就没了。 -/// 因此这里用的是进程级长命的 store(见 SharedDynamicStore 的说明), -/// 绝不能改成局部创建。 +/// One more thing to note: a value in SCDynamicStore is **owned by the session +/// that set it**, and the value disappears when that session is released. +/// That is why this uses the process-lifetime store (see the SharedDynamicStore +/// documentation); it must never be changed to a locally created one. void TryPublishDns(SCDynamicStoreRef store, std::string_view service_id, const std::vector& servers) { if (servers.empty()) { @@ -290,7 +312,7 @@ void TryPublishDns(SCDynamicStoreRef store, std::string_view service_id, } } -/// 等 DHCP 拿到租约。超时返回 false。 +/// Waits for the DHCP lease to arrive. Returns false on timeout. [[nodiscard]] bool WaitForLease(std::string_view interface_name) { const auto deadline = std::chrono::steady_clock::now() + kDhcpLeaseTimeout; while (std::chrono::steady_clock::now() < deadline) { @@ -302,11 +324,12 @@ void TryPublishDns(SCDynamicStoreRef store, std::string_view service_id, return QueryAddress(interface_name, SIOCGIFADDR).has_value(); } -/// 装一条**绑定到本接口**的默认路由。 +/// Installs a default route **bound to this interface**. /// -/// scoped 路由(RTF_IFSCOPE)是 macOS 支持的每接口独立默认路由:绑定到该接口的 -/// 流量走它,不影响系统主服务。DHCP 模式下 IPConfiguration 会自己装好这一条, -/// 只有静态模式需要我们补。 +/// A scoped route (RTF_IFSCOPE) is macOS's per-interface default route: +/// traffic bound to that interface takes it, and the system's primary +/// service is left alone. In DHCP mode IPConfiguration installs this one by +/// itself; only the static mode needs us to add it. [[nodiscard]] Status InstallScopedDefaultRoute(std::string_view interface_name, std::string_view router) { const std::vector add_arguments{ @@ -316,28 +339,30 @@ void TryPublishDns(SCDynamicStoreRef store, std::string_view service_id, status) { return tetherkit::Ok(); } - // 已经存在同样一条时 add 会失败(EEXIST),改成 change 再试一次。 + // When an identical route already exists, add fails with EEXIST -- retry + // once with change. const std::vector change_arguments{ "-n", "change", "-inet", "-ifscope", std::string{interface_name}, "default", std::string{router}}; return RunOrFail(kRoutePath, change_arguments, Text(Msg::kCapiWhatUpdateScopedRoute)); } -/// 把**全局**默认路由改到指定网关。 +/// Switches the **global** default route to the given gateway. [[nodiscard]] Status PromoteToGlobalDefaultRoute(std::string_view router) { const std::vector arguments{"-n", "change", "-inet", "default", std::string{router}}; if (const auto status = RunOrFail(kRoutePath, arguments, Text(Msg::kCapiWhatSwitchGlobalRoute)); status) { return tetherkit::Ok(); } - // 系统当前可能压根没有全局默认路由(没连任何网络),此时 change 会失败, - // 该用 add。这正是 USB 网络共享最典型的场景,必须处理。 + // The system may have no global default route at all (nothing is connected), + // in which case change fails and add is the right call. That is exactly the + // typical USB-tethering scenario, so it has to be handled. const std::vector add_arguments{"-n", "add", "-inet", "default", std::string{router}}; return RunOrFail(kRoutePath, add_arguments, Text(Msg::kCapiWhatAddGlobalRoute)); } -/// DHCP 模式下,从租约里取出网关地址。 +/// In DHCP mode, pulls the gateway address out of the lease. [[nodiscard]] std::optional QueryDhcpRouter(std::string_view interface_name) { const auto result = RunTool(kIpconfigPath, {"getoption", interface_name, "router"}); if (!result || !result->Succeeded()) { @@ -355,8 +380,9 @@ void TryPublishDns(SCDynamicStoreRef store, std::string_view service_id, RunOrFail(kIpconfigPath, {"set", std::string{interface_name}, "DHCP"}, Text(Msg::kCapiWhatStartDhcp))); - // 等租约。IPConfiguration 会自动完成四件事:拿租约、配 scoped DNS、 - // 装 scoped 默认路由、把服务发布到动态存储 —— 我们什么都不用做。 + // Wait for the lease. IPConfiguration automatically does four things: get + // the lease, set up the scoped DNS, install the scoped default route, and + // publish the service to the dynamic store -- we do none of it ourselves. if (!WaitForLease(interface_name)) { return std::unexpected(Error::Generic(Tr(Msg::kCapiDhcpTimeout, kDhcpLeaseTimeout.count()))); } @@ -402,7 +428,8 @@ void TryPublishDns(SCDynamicStoreRef store, std::string_view service_id, dns_servers.emplace_back(server); } - // 地址仍然经 IPConfiguration 下发,理由见文件头。 + // The address still goes out through IPConfiguration; see the file header + // for the reason. TETHERKIT_RETURN_IF_ERROR(RunOrFail( kIpconfigPath, {"set", std::string{interface_name}, "MANUAL", config.address, config.netmask}, @@ -441,10 +468,14 @@ void tk_ip_config_init(tk_ip_config_t* out_config) { return; } *out_config = tk_ip_config_t{}; - // 默认 DHCP:RNDIS 设备几乎总是自带 DHCP 服务器,这是绝大多数用户唯一需要的选项。 + // Default is DHCP: an RNDIS device almost always ships with its own DHCP + // server, and for the vast majority of users that is the only option they + // need. out_config->mode = TK_IP_MODE_DHCP; - // 默认**不**抢全局默认路由。只有在同时存在更高优先级的连通服务时才需要它, - // 而 USB 网络共享的典型场景恰恰是没有别的网络可用 —— 那时本网卡自然就是主服务。 + // Default: do **not** grab the global default route. It is only needed when + // a higher-priority connected service also exists, and the typical + // USB-tethering scenario is precisely that no other network is available -- + // there this interface naturally becomes the primary service. out_config->set_default_route = false; } @@ -498,8 +529,9 @@ tk_result_t tk_net_clear(const char* interface_name, tk_error_t* out_error) { return TK_ERR_PERMISSION; } - // 先撤掉我们自己写的 DNS 键(如果有),再让 IPConfiguration 拆服务。 - // 顺序反了的话服务已经没了,键会变成没人认领的孤儿。 + // Remove the DNS key we wrote ourselves (if any) first, then let + // IPConfiguration tear the service down. The other way round, the service is + // already gone and the key becomes an unclaimed orphan. if (SCDynamicStoreRef store = SharedDynamicStore(); store != nullptr) { if (const std::optional service_id = FindServiceId(store, interface_name); service_id.has_value()) { @@ -528,7 +560,7 @@ tk_result_t tk_net_query(const char* interface_name, tk_net_state_t* out_state, } *out_state = tk_net_state_t{}; - // ---- 地址与掩码:内核里的真实状态 ---- + // ---- Address and netmask: the real state in the kernel ---- if (const std::optional address = QueryAddress(interface_name, SIOCGIFADDR); address.has_value()) { out_state->has_address = true; @@ -539,10 +571,11 @@ tk_result_t tk_net_query(const char* interface_name, tk_net_state_t* out_state, CopyText(out_state->netmask, *netmask); } - // ---- 服务级信息:网关、DNS、配置方式 ---- + // ---- Service-level information: gateway, DNS, configuration method ---- SCDynamicStoreRef store = SharedDynamicStore(); if (store == nullptr) { - // 拿不到动态存储不算查询失败 —— 地址那一半已经有了,照常返回。 + // Not being able to reach the dynamic store is not a query failure -- the + // address half is already there, so return as usual. return TK_OK; } @@ -556,12 +589,15 @@ tk_result_t tk_net_query(const char* interface_name, tk_net_state_t* out_state, } CopyText(out_state->method, StringField(ipv4.Get(), CFSTR("ConfigMethod"))); - // DHCP 字典只在 DHCP 模式下存在,State 是 BOUND / INIT 之类。 + // The DHCP dictionary only exists in DHCP mode; State is BOUND / INIT and + // the like. const ScopedCFRef dhcp = CopyServiceEntry(store, *service_id, "DHCP"); CopyText(out_state->service_state, StringField(dhcp.Get(), CFSTR("State"))); - // DNS 一律回读**系统里真实生效的**,而不是复述我们下发的值 —— - // 静态模式下 DNS 能不能生效取决于 IPMonitor 认不认,只有回读才准。 + // The DNS is always read back from **what is actually in effect in the + // system**, never restated from the value we pushed down -- whether DNS + // works in static mode depends on IPMonitor accepting it, and only a + // read-back is accurate. const ScopedCFRef dns = CopyServiceEntry(store, *service_id, "DNS"); const std::vector servers = StringArrayField(dns.Get(), CFSTR("ServerAddresses")); for (const std::string& server : servers) { diff --git a/src/capi/orphan_cleanup.cc b/src/capi/orphan_cleanup.cc index 88d1990..cadf977 100644 --- a/src/capi/orphan_cleanup.cc +++ b/src/capi/orphan_cleanup.cc @@ -1,16 +1,20 @@ -// 孤儿 feth 网卡的登记与清理。 +// Registry and cleanup of orphan feth interfaces. // -// ★ 要解决的问题 ★ -// FethInterface 是 RAII 的,正常退出时网卡会被销毁。但**进程被 SIGKILL 时 -// 析构根本不跑**,网卡就永远留在内核里了 —— 而 SIGKILL 是任何信号处理器都 -// 拦不住的,所以「装个信号处理器」解决不了这件事。 +// The problem to solve +// FethInterface is RAII, so on a normal exit the interface is destroyed. +// But when the **process is SIGKILLed the destructor does not run at all**, +// and the interface stays in the kernel forever -- and SIGKILL cannot be +// caught by any signal handler, so "install a signal handler" does not +// solve this. // -// 唯一可靠的兜底是把创建过的接口名落盘,下次启动时清理。本文件就是那个落盘 -// 与清理的实现。 +// The only reliable backstop is to persist the names of the interfaces we +// created and clean them up on the next startup. This file is that on-disk +// registry and the cleanup. // -// ★ 为什么不是「启动时销毁所有 feth」★ -// feth 是公共设施,别的程序(或用户手工 ifconfig)也可能在用。只销毁我们 -// 自己登记过的,才不会误伤。 +// Why not "destroy every feth at startup" +// feth is a shared facility; other programs (or the user's own ifconfig +// invocations) may be using it too. Only destroying the ones we registered +// ourselves avoids collateral damage. #include #include @@ -35,14 +39,16 @@ using tetherkit::capi::ClearError; using tetherkit::capi::FillGenericError; using tetherkit::capi::IsValidFethName; -/// 登记文件路径。 +/// Path of the registry file. /// -/// 放 /var/run(= /private/var/run)而不是 /tmp:这里只有 root 可写,而能写 -/// 这个文件就等于能让我们去销毁任意名字的接口 —— 虽然名字有 feth<数字> 的 -/// 校验兜底,但把权限收紧成 root-only 才是正确的第一道防线。 +/// In /var/run (= /private/var/run) rather than /tmp: only root can write +/// there, and being able to write this file is equivalent to being able to +/// make us destroy an interface of any name -- the `feth` validation +/// is a backstop, but tightening the permissions to root-only is the correct +/// first line of defense. /// -/// 系统重启时 /var/run 会被清空,而重启也会顺带清掉所有 feth —— 两者的生命周期 -/// 恰好一致,不需要额外处理陈旧条目。 +/// A reboot empties /var/run, and a reboot also takes every feth with it -- +/// the two lifetimes coincide, so stale entries need no extra handling. constexpr const char* kRegistryPath = "/var/run/tetherkit-interfaces"; std::mutex& RegistryMutex() { @@ -58,7 +64,8 @@ std::mutex& RegistryMutex() { } std::string line; while (std::getline(input, line)) { - // 去掉可能的尾随空白,再做一次名字校验 —— 文件内容永远当作不可信输入。 + // Strip possible trailing whitespace, then validate the name once more -- + // the file contents are always treated as untrusted input. while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) { line.pop_back(); } @@ -70,7 +77,8 @@ std::mutex& RegistryMutex() { } void WriteRegistry(const std::vector& names) noexcept { - // 空列表就把文件删掉,省得留一个空文件让人以为还有残留。 + // An empty list means deleting the file, so nobody is left looking at an + // empty file and wondering whether something is still dangling. if (names.empty()) { ::unlink(kRegistryPath); return; @@ -84,13 +92,16 @@ void WriteRegistry(const std::vector& names) noexcept { } } -/// 安装给 net::SetInterfaceRegistry 的回调。 +/// Installs the callback for net::SetInterfaceRegistry. /// -/// 每次创建/销毁都整读整写一遍文件。听起来浪费,但一次会话只创建两张网卡, -/// 而换成「只追加」就得处理销毁时的行删除与文件增长,复杂度不值得。 +/// Every create/destroy rewrites the whole file. That sounds wasteful, but a +/// session creates only two interfaces, and switching to "append only" would +/// mean handling line removal on destroy plus unbounded file growth -- not +/// worth the complexity. void OnInterfaceChanged(std::string_view name, bool created) noexcept { - // 整个回调必须 noexcept —— 销毁路径可能在析构里。fstream 默认不抛异常 - // (没有 exceptions() 设置),所以这里只要不自己 throw 就是安全的。 + // The whole callback must be noexcept -- the destroy path may run inside a + // destructor. fstream does not throw by default (no exceptions() set), so + // as long as we do not throw ourselves it is safe. const std::lock_guard guard(RegistryMutex()); std::vector names = ReadRegistry(); @@ -127,9 +138,12 @@ tk_result_t tk_cleanup_orphan_interfaces(size_t* out_removed, tk_error_t* out_er return TK_ERR_PERMISSION; } - // ⚠️ 销毁循环里**绝不能**持有 RegistryMutex:DestroyInterfaceByName 成功后会 - // 触发登记回调,而回调要拿同一把锁 —— std::mutex 不可重入,持着进去就是当场 - // 自等死锁。所以这里把「读」「销毁」「收尾」拆成三段,锁只在头尾两段持有。 + // The destroy loop must **never** hold RegistryMutex: a successful + // DestroyInterfaceByName triggers the registry callback, and the callback + // takes the very same lock -- std::mutex is not reentrant, so entering it + // that way is an instant self-deadlock. Therefore "read", "destroy" and + // "finalize" are split into three parts, and the lock is held only in the + // first and the last. std::vector names; { const std::lock_guard guard(RegistryMutex()); @@ -141,8 +155,10 @@ tk_result_t tk_cleanup_orphan_interfaces(size_t* out_removed, tk_error_t* out_er std::size_t removed = 0; for (const std::string& name : names) { - // 销毁失败最常见的原因是接口已经不存在了(比如系统重启过),那正是我们 - // 想要的结果。真正的失败只记日志,不阻断其余条目。 + // The most common reason for a failed destroy is that the interface no + // longer exists (the system rebooted, say), which is exactly what we + // wanted. A genuine failure is only logged and does not stop the + // remaining entries. if (const auto status = tetherkit::net::DestroyInterfaceByName(name); status) { ++removed; continue; @@ -150,8 +166,10 @@ tk_result_t tk_cleanup_orphan_interfaces(size_t* out_removed, tk_error_t* out_er TETHERKIT_DEBUG_TR(Msg::kCapiOrphanAlreadyGone, name); } - // 收尾:能销毁的已经由回调逐行删掉了,剩下的是「本来就不在内核里」的条目, - // 留着只会让下次启动重复尝试,一并清空。 + // Finalize: the ones that could be destroyed were already removed line by + // line by the callback; what is left are the entries that "were not in the + // kernel to begin with", and keeping them would only make the next startup + // retry forever, so they are cleared as well. { const std::lock_guard guard(RegistryMutex()); WriteRegistry({}); diff --git a/src/capi/process_runner.cc b/src/capi/process_runner.cc index 8060537..d2e5536 100644 --- a/src/capi/process_runner.cc +++ b/src/capi/process_runner.cc @@ -15,7 +15,7 @@ extern char** environ; // NOLINT(cppcoreguidelines-avoid-non-const-global-varia namespace tetherkit::capi { namespace { -/// 读满一个管道直到对端关闭。 +/// Reads a pipe until the far end closes it. std::string ReadAll(int fd) { std::string content; std::array buffer{}; @@ -25,7 +25,8 @@ std::string ReadAll(int fd) { content.append(buffer.data(), static_cast(count)); continue; } - // count == 0 是 EOF;EINTR 要重试,其余错误当作读完。 + // count == 0 is EOF; EINTR has to be retried, every other error is + // treated as "done reading". if (count < 0 && errno == EINTR) { continue; } @@ -36,10 +37,11 @@ std::string ReadAll(int fd) { Result Spawn(std::string_view executable, const std::vector& arguments) { - // ---- 组装 argv ---- + // ---- Assemble argv ---- // - // posix_spawn 要 char* const[],而我们只有 const 字符串。这些指针在 - // posix_spawn 返回前不会被修改,按 POSIX 规定这样传是合法的。 + // posix_spawn wants a char* const[], but we only have const strings. These + // pointers are not modified before posix_spawn returns, and POSIX says + // passing them that way is legal. const std::string executable_path{executable}; std::vector argv; argv.reserve(arguments.size() + 2); @@ -49,7 +51,7 @@ Result Spawn(std::string_view executable, } argv.push_back(nullptr); - // ---- 建管道收输出 ---- + // ---- Create the pipe to collect the output ---- std::array pipe_fds{-1, -1}; if (::pipe(pipe_fds.data()) != 0) { return std::unexpected(Error::FromErrno(0, Tr(Msg::kCapiPipeFailed))); @@ -61,7 +63,8 @@ Result Spawn(std::string_view executable, ::close(pipe_fds[1]); return std::unexpected(Error::FromErrno(rc, Tr(Msg::kCapiSpawnFileActionsFailed))); } - // 子进程:关掉读端,把写端接到 stdout 与 stderr,然后关掉原始写端。 + // Child: close the read end, dup the write end onto stdout and stderr, then + // close the original write end. ::posix_spawn_file_actions_addclose(&actions, pipe_fds[0]); ::posix_spawn_file_actions_adddup2(&actions, pipe_fds[1], STDOUT_FILENO); ::posix_spawn_file_actions_adddup2(&actions, pipe_fds[1], STDERR_FILENO); @@ -71,8 +74,9 @@ Result Spawn(std::string_view executable, const int spawn_rc = ::posix_spawn(&child, executable_path.c_str(), &actions, nullptr, argv.data(), environ); ::posix_spawn_file_actions_destroy(&actions); - // 父进程必须立刻关掉写端,否则 ReadAll 永远等不到 EOF —— 自己还握着一个 - // 写端,管道就不会关。这是最经典的 pipe 死锁。 + // The parent must close the write end immediately, otherwise ReadAll waits + // for EOF forever -- we still hold a write end ourselves, so the pipe never + // closes. This is the most classic pipe deadlock. ::close(pipe_fds[1]); if (spawn_rc != 0) { @@ -81,8 +85,9 @@ Result Spawn(std::string_view executable, Error::FromErrno(spawn_rc, Tr(Msg::kCapiExecFailed, executable_path))); } - // 必须**先读空管道再 waitpid**:反过来的话,子进程写满管道缓冲(64 KiB)后 - // 会阻塞在 write 上,而我们阻塞在 waitpid 上,双方都不动。 + // The pipe must be **drained before waitpid**: the other way round, the + // child fills the pipe buffer (64 KiB), blocks in write(), and we block in + // waitpid() -- neither side moves. ProcessResult result; result.output = ReadAll(pipe_fds[0]); ::close(pipe_fds[0]); @@ -98,7 +103,8 @@ Result Spawn(std::string_view executable, if (WIFEXITED(status)) { result.exit_code = WEXITSTATUS(status); } else if (WIFSIGNALED(status)) { - // 被信号打死:用 128+signo 表示,和 shell 的惯例一致,便于对着日志排查。 + // Killed by a signal: reported as 128+signo, following the shell + // convention, which makes reading the log easier. result.exit_code = 128 + WTERMSIG(status); } else { result.exit_code = -1; diff --git a/src/capi/process_runner.h b/src/capi/process_runner.h index ea371dd..d58660e 100644 --- a/src/capi/process_runner.h +++ b/src/capi/process_runner.h @@ -1,15 +1,19 @@ -// 执行系统工具(ipconfig / route)并收集输出。 +// Runs a system tool (ipconfig / route) and collects its output. // -// ★ 为什么不用 system() / popen() ★ -// 那两个都会经过 /bin/sh。网卡名、IP 地址这些参数虽然我们都校验过,但只要 -// 经过 shell,防线就依赖「校验有没有漏」这一条;用 posix_spawn 直接传 argv -// 数组,shell 元字符从原理上就没有解释它们的地方。 +// Why not system() / popen() +// Both of those go through /bin/sh. The arguments -- interface names, IP +// addresses -- are all validated by us, but as long as they pass through a +// shell the whole defense rests on "the validation has no hole"; passing +// the argv array straight to posix_spawn means shell metacharacters have +// nowhere to be interpreted in the first place. // -// ★ 为什么用外部工具而不是自己发 ioctl ★ -// `ipconfig set` 走的是 IPConfiguration 的正规注册路径,由它建立的服务会被 -// IPMonitor 采纳(DNS 生效、默认路由装上);而自己 SIOCAIFADDR 配出来的地址 -// configd 完全不认。这一条是 docs/GUI-SPIKE.md 第 3.2 / 6.3 节实测确认的, -// 不能为了「少一次 fork」倒退回裸 ioctl。 +// Why external tools instead of issuing the ioctls ourselves +// `ipconfig set` goes through IPConfiguration's proper registration path, +// and the service it creates is adopted by IPMonitor (DNS takes effect, +// the default route gets installed); an address configured by our own +// SIOCAIFADDR is not recognized by configd at all. This was confirmed by +// measurement in docs/GUI-SPIKE.md sections 3.2 / 6.3, so it must not be +// reverted to bare ioctls just to save one fork. #pragma once #include @@ -23,27 +27,32 @@ namespace tetherkit::capi { struct ProcessResult { int exit_code = 0; - /// 子进程的 stdout 与 stderr 合并后的内容。 + /// The child's stdout and stderr merged together. /// - /// 合并是刻意的:这些工具的报错有的走 stdout 有的走 stderr,分开收集只会让 - /// 「失败了但我们没拿到原因」这种情况更常见。 + /// The merge is deliberate: some of these tools report errors on stdout and + /// some on stderr, and collecting them separately only makes "it failed + /// and we never got the reason" more likely. std::string output; [[nodiscard]] bool Succeeded() const noexcept { return exit_code == 0; } }; -/// 执行一个可执行文件并等它结束。 +/// Runs an executable and waits for it to finish. /// -/// @param executable 绝对路径。必须是绝对路径 —— 依赖 PATH 会让行为随调用者的 -/// 环境变化,而 helper 是 launchd 拉起的,PATH 与终端里不同。 -/// @param arguments 不含 argv[0],函数内部会补上。 +/// @param executable Absolute path. It must be an absolute path -- relying on +/// PATH makes the behavior depend on the caller's +/// environment, and the helper is launched by launchd, +/// whose PATH differs from the one in a terminal. +/// @param arguments Without argv[0]; the function appends it. /// -/// 返回错误仅表示「没能把进程跑起来或没等到它结束」;进程跑起来但返回非零,是 -/// 成功返回一个 exit_code != 0 的 ProcessResult —— 调用方要自己判断。 +/// An error return means only "could not start the process, or did not wait +/// for it to finish"; a process that starts and exits non-zero is a success +/// that returns a ProcessResult with exit_code != 0 -- the caller has to +/// judge that itself. [[nodiscard]] Result RunTool(std::string_view executable, std::initializer_list arguments); -/// 供动态构造参数列表的重载。 +/// The overload for a dynamically built argument list. [[nodiscard]] Result RunTool(std::string_view executable, const std::vector& arguments); diff --git a/src/capi/session.cc b/src/capi/session.cc index a4771e4..59766f3 100644 --- a/src/capi/session.cc +++ b/src/capi/session.cc @@ -1,7 +1,8 @@ -// 会话的 C ABI:生命周期、状态快照、事件轮询。 +// The C ABI of the session: lifecycle, status snapshot, event polling. // -// 这一层几乎没有逻辑,只做三件事:POD 配置 → RuntimeConfig、RuntimeSnapshot → -// POD 状态、RuntimeEvent → 环形队列。真正的编排全在 core::Runtime 里。 +// This layer holds almost no logic; it does three things: POD config -> +// RuntimeConfig, RuntimeSnapshot -> POD status, RuntimeEvent -> ring queue. +// The real orchestration lives in core::Runtime. #include #include #include @@ -31,11 +32,13 @@ using tetherkit::core::RunState; using tetherkit::core::RuntimeEvent; // --------------------------------------------------------------------------- -// 枚举值对齐检查 +// Enum value alignment check // -// C 侧的枚举是照 C++ 侧抄的数值,两边靠「顺序恰好一致」直接强转。这种耦合一旦 -// 有人在中间插入一个枚举值就会静默错位(界面显示错状态,且没有任何报错), -// 所以在编译期把它焊死。 +// The C-side enums copy the numeric values of the C++ side and rely on a +// direct cast plus "the order happens to match". That coupling goes silently +// wrong the moment someone inserts an enum value in the middle (the UI shows +// the wrong status, and nothing reports an error), so it is nailed down at +// compile time. // --------------------------------------------------------------------------- static_assert(static_cast(RunState::kIdle) == TK_RUN_IDLE); static_assert(static_cast(RunState::kStarting) == TK_RUN_STARTING); @@ -58,16 +61,19 @@ static_assert(static_cast(RuntimeEvent::Kind::kDeviceReset) == TK_EVENT_DEV static_assert(static_cast(RuntimeEvent::Kind::kFatal) == TK_EVENT_FATAL); static_assert(static_cast(RuntimeEvent::Kind::kRunState) == TK_EVENT_RUN_STATE); -/// 事件环形缓冲。 +/// The event ring buffer. /// -/// 容量 128 的依据:事件只在状态迁移时产生,一次完整的启动序列约 10 条。128 -/// 足以覆盖「设备反复插拔 + 链路抖动」这类连续事件,而 GUI 每 500 ms 就会取空。 -/// 满了丢最旧 —— 事件只用于做动画和提示,权威状态在快照里,漏几条不影响正确性。 +/// Why a capacity of 128: events are only produced on state transitions, and a +/// full startup sequence is about 10 of them. 128 is enough to cover bursts +/// like "the device is plugged and unplugged repeatedly plus link flapping", +/// and the GUI drains it every 500 ms. The oldest is dropped when it fills -- +/// events only drive animations and notifications, the authoritative state is +/// in the snapshot, and missing a few does not affect correctness. constexpr std::size_t kEventRingCapacity = 128; class EventRing final : public tetherkit::core::RuntimeEventSink { public: - /// 由控制线程调用。 + /// Called by the control thread. void OnRuntimeEvent(const RuntimeEvent& event) noexcept override { const std::lock_guard guard(mutex_); if (count_ == kEventRingCapacity) { @@ -83,7 +89,7 @@ class EventRing final : public tetherkit::core::RuntimeEventSink { ++count_; } - /// 由宿主线程调用。 + /// Called by the host thread. std::size_t Drain(tk_event_t* out_events, std::size_t capacity) noexcept { const std::lock_guard guard(mutex_); std::size_t taken = 0; @@ -105,10 +111,12 @@ class EventRing final : public tetherkit::core::RuntimeEventSink { std::size_t count_ = 0; }; -/// 取值,0 视作「用库的默认值」。 +/// Reads a value, treating 0 as "use the library default". /// -/// 对调优旋钮宽容而不是报错:GUI 可能只关心 MTU,把其余字段留空,硬要求它们 -/// 非零只会逼调用方复制一遍默认值 —— 而复制出来的常量迟早和库里的不同步。 +/// Being lenient about the tuning knobs instead of erroring out: the GUI may +/// only care about the MTU and leave the rest at zero, and demanding non-zero +/// values would only force the caller to copy the defaults -- and copied +/// constants drift out of sync with the library sooner or later. [[nodiscard]] std::uint32_t OrDefault(std::uint32_t value, std::uint32_t fallback) noexcept { return value != 0 ? value : fallback; } @@ -143,12 +151,14 @@ class EventRing final : public tetherkit::core::RuntimeEventSink { } // namespace -// 会话对象。 +// The session object. // -// ★ 成员声明顺序即销毁顺序的倒序,这里不能乱调 ★ -// events 必须声明在 runtime **之前**,这样销毁时 runtime 先走 —— 它的析构 -// 会 join 控制线程,而控制线程直到最后一刻都可能往 events 里塞事件。 -// 反过来就是 use-after-free。 +// The declaration order of the members is the reverse of the destruction +// order, and it must not be rearranged +// events has to be declared **before** runtime, so that runtime is destroyed +// first -- its destructor joins the control thread, and the control thread +// may push events into events until the very last moment. +// The other way around is a use-after-free. struct tk_session { EventRing events; std::unique_ptr runtime; @@ -177,8 +187,10 @@ tk_session_t* tk_session_create(const tk_session_config_t* config, tk_error_t* o return nullptr; } - // 会话是唯一会创建 feth 的入口,登记回调在这里装上就够 —— 免 root 的那组 - // 接口(版本、枚举、预检)因此保持零副作用,不会去碰 /var/run。 + // The session is the only entry point that creates an feth, so installing + // the registry callback here is enough -- the root-free group of entry + // points (version, enumeration, preflight) therefore stays free of side + // effects and never touches /var/run. tetherkit::capi::InstallInterfaceRegistry(); auto session = std::unique_ptr(new (std::nothrow) tk_session()); @@ -205,8 +217,9 @@ tk_result_t tk_session_start(tk_session_t* session, tk_error_t* out_error) { if (const auto status = session->runtime->Start(); !status) { FillError(out_error, status.error()); - // 唯一会同步失败的是 root 检查(见 Runtime::Start),单独给个专门的码, - // GUI 好据此弹「需要授权」而不是笼统的「启动失败」。 + // The only synchronous failure is the root check (see Runtime::Start), so + // it gets a dedicated code -- that lets the GUI pop up "authorization + // required" instead of a generic "start failed". return tetherkit::net::IsRunningAsRoot() ? TK_ERR_FAILED : TK_ERR_PERMISSION; } return TK_OK; @@ -221,7 +234,8 @@ tk_result_t tk_session_stop(tk_session_t* session) { } void tk_session_destroy(tk_session_t* session) { - // Runtime 的析构会 Stop()(幂等),所以这里直接 delete 就够。 + // The Runtime destructor calls Stop() (idempotent), so a plain delete is + // enough here. delete session; // NOLINT(cppcoreguidelines-owning-memory) } @@ -243,8 +257,10 @@ tk_result_t tk_session_status_get(tk_session_t* session, tk_session_status_t* ou CopyText(out_status->vendor_description, snapshot.device_info.vendor_description); CopyText(out_status->device_description, snapshot.device_description); - // 用 permanent 而非 current:RNDIS 语义下设备就是这块网卡,对端的 ARP 表与 - // DHCP 租约都按永久地址建立,界面上显示它才对得上用户在路由器里看到的条目。 + // The permanent address rather than the current one: under RNDIS semantics + // the device *is* this interface, and the peer's ARP table and DHCP lease + // are both built from the permanent address, so showing it in the UI is + // what matches what the user sees on the router. static_assert(sizeof(out_status->device_mac) == std::tuple_size_v); std::ranges::copy(snapshot.device_info.permanent_address, std::begin(out_status->device_mac)); @@ -262,8 +278,10 @@ tk_result_t tk_session_status_get(tk_session_t* session, tk_session_status_t* ou out_status->link_kernel_drops = snapshot.bridge.link_kernel_drops; out_status->tx_backpressure = snapshot.bridge.tx_backpressure_events; - // 用库这边的单调时钟给快照打时间戳,而不是让宿主用自己的时钟做差: - // 两次拉取之间的真实间隔会被调度拉长,用宿主的定时器周期当分母会把速率算高。 + // The snapshot is timestamped with the library's monotonic clock rather + // than letting the host difference its own clock readings: scheduling + // stretches the real interval between two fetches, and using the host's + // timer period as the denominator overstates the rate. out_status->monotonic_nanos = static_cast(tetherkit::MonotonicNanos()); CopyText(out_status->fatal, snapshot.fatal_message); diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt index 75bebc9..f307ba4 100644 --- a/src/common/CMakeLists.txt +++ b/src/common/CMakeLists.txt @@ -1,8 +1,9 @@ -# tk_common —— 基础设施层 +# tk_common -- the infrastructure layer # -# 不依赖任何上层模块,也不依赖 libusb / 网络系统调用。唯一的外部依赖是 -# libusb 的头文件(error.cc 需要 libusb_error_name 来翻译错误码),因此 -# 以 PRIVATE 方式链接,不把 libusb 传播给 tk_common 的使用者。 +# Depends on no higher-level module, nor on libusb / network syscalls. Its only +# external dependency is libusb's headers (error.cc needs libusb_error_name to +# translate error codes), so it is linked PRIVATEly and libusb is not propagated +# to tk_common's consumers. add_library( tk_common STATIC diff --git a/src/common/error.cc b/src/common/error.cc index 730cb48..2bb6678 100644 --- a/src/common/error.cc +++ b/src/common/error.cc @@ -9,13 +9,15 @@ namespace tetherkit { namespace { -/// 线程安全的 strerror。 +/// Thread-safe strerror. /// -/// 不能用 std::strerror:它返回指向静态缓冲的指针,多线程同时调用会互相覆盖 -/// (clang-tidy 的 concurrency-mt-unsafe 就在提示这点)。 +/// std::strerror cannot be used: it returns a pointer to a static buffer, and +/// concurrent calls from multiple threads overwrite each other (clang-tidy's +/// concurrency-mt-unsafe exists precisely to flag this). std::string SafeStrerror(int err) { std::array buffer{}; - // Darwin 提供 XSI 版 strerror_r,返回 int:0 成功,ERANGE 表示缓冲太小。 + // Darwin provides the XSI variant of strerror_r, which returns int: + // 0 on success, ERANGE when the buffer is too small. if (::strerror_r(err, buffer.data(), buffer.size()) != 0) { return std::format("errno {}", err); } @@ -40,9 +42,11 @@ std::string Error::ToString() const { } case ErrorDomain::kRndis: { - // RNDIS_STATUS_* 到名字的映射只存在于 tk_rndis(rndis/protocol.cc), - // tk_common 不允许依赖上层模块,因此这里只输出原始数值 —— - // 状态码的符号名由 rndis 层在构造 Error 时拼进 context 串。 + // The RNDIS_STATUS_* to name mapping only lives in tk_rndis + // (rndis/protocol.cc); tk_common is not allowed to depend on upper + // modules, so this just prints the raw numeric value -- + // the rndis layer bakes the status code's symbolic name into the + // context string when it constructs the Error. return std::format("{} [RNDIS_STATUS: {:#010x}]", context_, static_cast(code_)); } diff --git a/src/common/logging.cc b/src/common/logging.cc index 9be0169..de3b62a 100644 --- a/src/common/logging.cc +++ b/src/common/logging.cc @@ -14,29 +14,36 @@ namespace tetherkit { namespace { -// 日志配置是进程级的单一状态,天然是可变全局量;用原子保证线程安全后, -// 再拆成单例类只会增加间接层而无实质收益,故此处整体豁免相关检查。 +// The log configuration is process-wide singleton state, naturally a mutable +// global; once it is made thread-safe with atomics, splitting it into a +// singleton class would only add indirection with no real benefit, so the +// related checks are exempted wholesale here. // NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) -/// 运行期日志下限。用原子而非 mutex:读取发生在每条日志的判断分支上。 +/// The runtime log floor. An atomic rather than a mutex: reads happen on the +/// branch of every log line. std::atomic g_runtime_level{LogLevel::kInfo}; -/// 是否输出 ANSI 颜色。惰性初始化为 `isatty(STDERR_FILENO)`。 +/// Whether ANSI colors are emitted. Lazily initialized to `isatty(STDERR_FILENO)`. std::atomic g_color_enabled{-1}; -/// 保护 stderr 的整行输出,避免多线程日志交错。 -/// 日志不在数据热路径上,锁竞争可以忽略。 +/// Protects stderr's whole-line output so that logs from multiple threads do +/// not interleave. Logging is not on the data hot path, so lock contention is +/// negligible. std::mutex& OutputMutex() { static std::mutex mutex; return mutex; } -/// 宿主安装的日志汇。sink 与 user 必须**成对**更新,所以用 OutputMutex 保护 -/// 而不是两个独立的原子 —— 两个原子无法一起原子更新,会读到错配的组合。 +/// The host-installed log sink. The sink and its user must be updated **as a +/// pair**, so this is protected by OutputMutex rather than two independent +/// atomics -- two atomics cannot be updated together atomically, and a reader +/// could observe a mismatched combination. LogSink g_log_sink = nullptr; void* g_log_sink_user = nullptr; -/// 线程名。thread_local 而非查询 pthread_getname_np:后者是系统调用。 +/// The thread name. thread_local rather than querying pthread_getname_np: +/// the latter is a syscall. constexpr std::size_t kThreadNameCapacity = 16; thread_local std::array t_thread_name{}; @@ -47,10 +54,11 @@ std::string_view CurrentThreadName() { return {t_thread_name.data(), std::strlen(t_thread_name.data())}; } -/// 级别标签与 ANSI 颜色码。 +/// Level labels and ANSI color codes. /// -/// 刻意用 `const char*` 而非 `std::string_view`:这些值会直接喂给 `%s`, -/// 而空 string_view 的 `data()` 是 nullptr,传给 printf 是未定义行为。 +/// Deliberately `const char*` instead of `std::string_view`: these values are +/// fed straight to `%s`, and the `data()` of an empty string_view is nullptr, +/// which is undefined behavior when passed to printf. struct LevelStyle { const char* label; const char* color; @@ -59,15 +67,15 @@ struct LevelStyle { LevelStyle StyleFor(LogLevel level) { switch (level) { case LogLevel::kTrace: - return {"TRACE", "\033[90m"}; // 亮黑(灰) + return {"TRACE", "\033[90m"}; // bright black (gray) case LogLevel::kDebug: - return {"DEBUG", "\033[36m"}; // 青 + return {"DEBUG", "\033[36m"}; // cyan case LogLevel::kInfo: - return {"INFO ", "\033[32m"}; // 绿 + return {"INFO ", "\033[32m"}; // green case LogLevel::kWarn: - return {"WARN ", "\033[33m"}; // 黄 + return {"WARN ", "\033[33m"}; // yellow case LogLevel::kError: - return {"ERROR", "\033[31m"}; // 红 + return {"ERROR", "\033[31m"}; // red case LogLevel::kOff: break; } @@ -83,9 +91,10 @@ bool ColorEnabled() { return cached != 0; } -/// 把当前墙上时间格式化成 `HH:MM:SS.mmm`。 +/// Formats the current wall-clock time as `HH:MM:SS.mmm`. /// -/// 用墙上时间而非单调时间:日志是给人看的,需要能和其他系统日志对齐。 +/// Wall time rather than monotonic time: logs are read by humans and need to +/// align with other system logs. void FormatTimestamp(std::array& out) { ::timespec ts{}; ::clock_gettime(CLOCK_REALTIME, &ts); @@ -122,7 +131,8 @@ void SetCurrentThreadName(std::string_view name) noexcept { const std::size_t copy_len = std::min(name.size(), kThreadNameCapacity - 1); std::memcpy(t_thread_name.data(), name.data(), copy_len); t_thread_name[copy_len] = '\0'; - // 同步给内核,使 lldb / Instruments / `sample` 也能看到有意义的线程名。 + // Push the name to the kernel too, so that lldb / Instruments / `sample` also + // see a meaningful thread name. ::pthread_setname_np(t_thread_name.data()); } @@ -143,18 +153,20 @@ void EmitLogLine(LogLevel level, std::string_view file, unsigned line, const char* color_off = color ? "\033[0m" : ""; const std::string_view thread_name = CurrentThreadName(); - // 用一次 fprintf 输出整行,配合互斥锁保证行不交错。 - // 不用 std::format 是为了避免这里也可能抛异常 —— 日志路径必须 noexcept。 - // file / thread_name / message 是可能不带终止符的 string_view, - // 因此统一用 `%.*s` 显式传长度。 + // One fprintf for the whole line, plus the mutex, guarantees lines do not + // interleave. Not std::format so that this cannot throw either -- the logging + // path must be noexcept. file / thread_name / message are string_views and + // may be unterminated, so `%.*s` always passes the length explicitly. const std::lock_guard guard(OutputMutex()); std::fprintf(stderr, "%s%s%s %s [%-10.*s] %.*s:%u %.*s\n", color_on, style.label, color_off, timestamp.data(), static_cast(thread_name.size()), thread_name.data(), static_cast(file.size()), file.data(), line, static_cast(message.size()), message.data()); - // 转交给宿主。在锁内调用是刻意的:这样宿主看到的行序与 stderr 完全一致。 - // 代价是 sink 里绝不能再打日志(会自等死锁),该约束写在 logging.h 上。 + // Forward to the host. Calling inside the lock is deliberate: the host then + // sees exactly the same line order as stderr. The cost is that the sink must + // never log again (it would wait on itself, deadlocking); the constraint is + // documented on logging.h. if (g_log_sink != nullptr) { g_log_sink(level, thread_name, message, g_log_sink_user); } diff --git a/src/common/scheduling.cc b/src/common/scheduling.cc index 81c4987..d43c084 100644 --- a/src/common/scheduling.cc +++ b/src/common/scheduling.cc @@ -22,10 +22,11 @@ void ConfigureCurrentThread(std::string_view name, ThreadRole role) noexcept { SetCurrentThreadName(name); const auto qos = static_cast(QosClassFor(role)); - // relative_priority 传 0 表示该 QoS 等级内的默认优先级。 + // relative_priority 0 means the default priority within that QoS class. const int rc = ::pthread_set_qos_class_self_np(qos, 0); if (rc != 0) { - // QoS 设置失败不影响功能,只影响性能,因此只告警不返回错误。 + // A QoS failure does not affect functionality, only performance, so this + // just warns instead of returning an error. TETHERKIT_WARN_TR(Msg::kCommonThreadQosFailed, static_cast(qos), rc); } } diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 50f173f..8651805 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -1,7 +1,8 @@ -# tk_core —— 数据路径桥接与运行时编排 +# tk_core -- data-path bridging and runtime orchestration # -# 把 tk_usb 的 RNDIS 数据通道与 tk_net 的链路后端对接起来,并管理数据路径线程。 -# 它是唯一同时依赖 usb / net / rndis 三个模块的层。 +# Wires tk_usb's RNDIS data channel to tk_net's link backend and manages the +# data-path threads. It is the only layer that depends on all three of +# usb / net / rndis at once. add_library( tk_core STATIC diff --git a/src/core/bridge.cc b/src/core/bridge.cc index 4a22a64..c8ed71a 100644 --- a/src/core/bridge.cc +++ b/src/core/bridge.cc @@ -12,14 +12,17 @@ namespace tetherkit::core { namespace { -/// TX 背压时单次等待空闲传输槽位的上限(毫秒)。 +/// Upper bound on how long one TX-backpressure wait may block for a free +/// transfer slot (milliseconds). /// -/// 取值只影响两件事的权衡:停机/暂停的响应延迟上界(每次醒来都会重查标志), -/// 与等待期间的无谓唤醒频率。槽位实际的释放周期是几百微秒(一次 bulk OUT 的 -/// 完成时间),绝大多数等待都由完成回调的 notify 提前唤醒,几乎不会等满。 +/// The value only trades off two things: the worst-case shutdown/pause +/// response latency (every wakeup rechecks the flags) and how often we wake +/// needlessly while waiting. Slots actually free up every few hundred +/// microseconds (the completion time of one bulk OUT), and most waits are cut +/// short by the completion callback's notify, so the bound is rarely reached. constexpr std::uint32_t kTxCapacityWaitMillis = 50; -/// 把字节数换算成 Mbps。 +/// Convert a byte count into Mbps. [[nodiscard]] double ToMegabitsPerSecond(std::uint64_t bytes, double seconds) { if (seconds <= 0.0) { return 0.0; @@ -53,9 +56,11 @@ Status Bridge::Start() { } stop_requested_.store(false, std::memory_order_release); - // 先让数据通道开始接收 —— 它会把帧推进 rx_ring_。 - // 顺序上必须在启动注入线程**之前**还是之后都可以(队列是有界的,满了会丢弃 - // 并计数),但先开接收能让链路一就绪就开始收,少丢几帧。 + // Start the data channel's receive side first -- it pushes frames into + // rx_ring_. The order relative to starting the injector thread does not + // matter (the queue is bounded, and it drops and counts when full), but + // opening receive first lets us collect frames the moment the link is ready, + // dropping a few fewer. TETHERKIT_RETURN_IF_ERROR(data_channel_->StartReceiving(*rx_ring_, counters_.rx)); running_.store(true, std::memory_order_release); @@ -72,23 +77,27 @@ Status Bridge::Start() { void Bridge::Stop() { if (!running_.exchange(false, std::memory_order_acq_rel)) { - return; // 没在跑或已经停过 + return; // not running, or already stopped } // --------------------------------------------------------------------------- - // 拆除顺序(顺序错了会漏帧或死锁) + // Teardown order (get it wrong and you leak frames or deadlock) // - // 1. 置停机标志 —— 两个线程的循环条件都看它; - // 2. 打断链路的阻塞 read() —— 否则 TX 抽取线程会一直卡在里面; - // 3. join 两个数据路径线程; - // 4. **最后**才停数据通道。 + // 1. set the stop flag -- both threads' loop conditions test it; + // 2. interrupt the link's blocking read() -- otherwise the TX extractor + // thread stays stuck inside it; + // 3. join the two data-path threads; + // 4. stop the data channel **last**. // - // 第 4 步必须最后做,原因有两个: - // * DataChannel::Shutdown() 要等在飞 USB 传输的回调全部回来,而那些回调会 - // 往 rx_ring_ 里写 —— 如果先停通道再 join,倒也没错;但反过来若先销毁了 - // rx_ring_ 就是 use-after-free。这里的顺序保证 rx_ring_ 一直活着。 - // * Shutdown() 绝不能在 libusb 事件线程上调用(会自己等自己死锁), - // 而这里是调用 Stop() 的那个线程(主线程或控制线程),安全。 + // Step 4 must be last for two reasons: + // * DataChannel::Shutdown() waits for all in-flight USB transfer + // callbacks to return, and those callbacks write into rx_ring_ -- if the + // channel is stopped before the joins that is still fine; but the other + // way around, destroying rx_ring_ first is a use-after-free. This order + // keeps rx_ring_ alive throughout. + // * Shutdown() must never be called on the libusb event thread (it waits + // on itself and deadlocks), and here we are on whichever thread called + // Stop() (main or control), which is safe. // --------------------------------------------------------------------------- stop_requested_.store(true, std::memory_order_release); @@ -107,7 +116,7 @@ void Bridge::Stop() { } // ============================================================================= -// RX 注入线程:FrameRing → 链路 +// RX injector thread: FrameRing -> link // ============================================================================= void Bridge::SetPaused(bool paused) noexcept { @@ -116,11 +125,14 @@ void Bridge::SetPaused(bool paused) noexcept { return; } - // 等 RX 注入线程确认它确实停住了。没有这一步,本函数返回之后 worker 仍可能 - // 把一批已经取出的帧写到链路上:它可能刚过完上面那个标志检查。 + // Wait for the RX injector thread to confirm it has actually parked. Without + // this, once this function returns the worker could still write a batch it + // already dequeued to the link: it may have just cleared the flag check + // above. // - // 只在线程确实在跑的时候等 —— Start() 之前或 Stop() 之后没人会来置位, - // 无条件等会永远不返回。 + // Only wait while the threads are actually running -- nobody sets the flag + // before Start() or after Stop(), so an unconditional wait would never + // return. while (running_.load(std::memory_order_acquire) && !stop_requested_.load(std::memory_order_acquire) && !rx_paused_ack_.load(std::memory_order_acquire)) { @@ -135,22 +147,28 @@ void Bridge::RunReceiveInjector() noexcept { std::uint32_t idle_spins = 0; while (!stop_requested_.load(std::memory_order_acquire)) { - // 先撤销确认位,再判断是否暂停。顺序不能反 —— 留着上一轮的 true 会让 - // SetPaused(true) 误以为本线程已经停住,而它其实正要去取帧。 + // Clear the ack flag before deciding whether to pause. The order cannot + // be reversed -- leaving last round's true in place would make + // SetPaused(true) believe this thread has already parked while it is + // actually about to fetch frames. rx_paused_ack_.store(false, std::memory_order_release); if (paused_.load(std::memory_order_acquire)) { - // 复位期间:不搬运,但也不丢弃 —— 队列里的帧等恢复后继续发。 + // During a reset: do not move frames, but do not drop them either -- + // frames in the queue resume once we unpause. // - // 置确认位是给 SetPaused(true) 看的:从这里到下一轮看见 paused_ 变回 - // false 之前,本线程不会碰 rx_ring_,所以这个确认是可信的。 + // Setting the ack bit is for SetPaused(true) to see: from here until the + // next round observes paused_ back at false, this thread will not touch + // rx_ring_, so the ack is trustworthy. rx_paused_ack_.store(true, std::memory_order_release); std::this_thread::yield(); continue; } - // 攒一批再写。批量会话在作用域结束时才一次性释放槽位,因此期间取出的 - // 全部视图都保持有效 —— 这正是「零拷贝交给一次批量 write()」的前提。 + // Accumulate a batch before writing. The batch session releases its slots + // only when it goes out of scope, so every view dequeued meanwhile stays + // valid -- that is exactly the precondition for "hand zero-copy to a single + // batched write()". rx_batch_.clear(); { auto batch = rx_ring_->BeginBatchRead(); @@ -163,8 +181,10 @@ void Bridge::RunReceiveInjector() noexcept { } if (rx_batch_.empty()) { - // 队列空。先自旋一小会儿覆盖「生产者正在写下一帧」这种极短空窗, - // 再让出 CPU —— 纯自旋会白烧一个核,直接 yield 又会在高吞吐下抬高延迟。 + // Queue empty. Spin briefly first to cover the tiny window where the + // producer is writing the next frame, then yield the CPU -- pure + // spinning burns a core for nothing, while yielding outright raises + // latency under high throughput. if (++idle_spins < config_.rx_spin_before_yield) { continue; } @@ -179,29 +199,34 @@ void Bridge::RunReceiveInjector() noexcept { counters_.rx.AddIoError(); TETHERKIT_WARN_TR(Msg::kCoreLinkWriteFailed, rx_batch_.size(), result.error().ToString()); - // 写失败不退出线程 —— 可能只是接口暂时 down。批量会话照常释放槽位 - // (帧已经没法送出去了,留着只会堵住队列)。 + // A write failure does not exit the thread -- the interface may just + // be temporarily down. The batch session still releases its slots + // normally (the frames cannot be delivered anymore; keeping them would + // only jam the queue). } else { if (result->frames_skipped != 0) { counters_.rx.AddDroppedOversize(result->frames_skipped); } const auto written = result->frames_written; if (written < rx_batch_.size()) { - // 链路没吃下整批(内核发送队列满)。剩下的帧已经从队列里取出来了, - // 只能丢弃并计数 —— 这是真实的丢包,必须让运维看得见。 + // The link did not swallow the whole batch (kernel send queue full). + // The remaining frames are already out of the queue, so they can only + // be dropped and counted -- this is real packet loss and must stay + // visible to operators. counters_.rx.AddDroppedFull(rx_batch_.size() - written); } - // 注意:内核侧的 bs_drop 是**读**方向的统计,由 TX 抽取线程从 - // ReadFrames 的结果里更新,这里不碰它。 + // Note: the kernel-side bs_drop is a **read**-direction statistic, + // updated by the TX extractor thread from ReadFrames results; we do + // not touch it here. } - } // 批量会话析构 → 一次 PublishRead(n) + } // batch session destructs -> a single PublishRead(n) } TETHERKIT_DEBUG_TR(Msg::kCoreRxInjectorExited); } // ============================================================================= -// TX 抽取线程:链路 → USB +// TX extractor thread: link -> USB // ============================================================================= void Bridge::RunTransmitExtractor() noexcept { @@ -209,13 +234,15 @@ void Bridge::RunTransmitExtractor() noexcept { TETHERKIT_DEBUG_TR(Msg::kCoreTxExtractorStarted); while (!stop_requested_.load(std::memory_order_acquire)) { - // 阻塞读。BPF 在 BIOCIMMEDIATE=1 下会自动把期间累积的包整批交付, - // 所以这里天然就是「低速低延迟、高速大批量」,不需要我们自己攒批。 + // Blocking read. With BIOCIMMEDIATE=1 the BPF hands over whatever + // accumulated in one go, so this is naturally "low rate, low latency; + // high rate, large batches" -- we do not need to batch ourselves. const auto batch = link_->ReadFrames(); if (!batch) { counters_.tx.AddIoError(); TETHERKIT_WARN_TR(Msg::kCoreLinkReadFailed, batch.error().ToString()); - // 读失败可能是接口被拆了。稍等再试,避免忙循环刷日志。 + // A read failure can mean the interface was torn down. Wait a bit and + // retry to avoid a busy loop spamming the log. std::this_thread::sleep_for(std::chrono::milliseconds(10)); continue; } @@ -223,21 +250,25 @@ void Bridge::RunTransmitExtractor() noexcept { link_kernel_drops_.store(batch->kernel_drops, std::memory_order_relaxed); if (batch->frames.empty()) { - continue; // 读超时到期且期间无包,正常 + continue; // read timeout expired with no packets in between; normal } if (paused_.load(std::memory_order_acquire)) { - // 复位期间设备会丢弃所有未完成的数据包,往它发只是浪费。 + // During a reset the device discards all outstanding packets, so + // sending to it is pure waste. counters_.tx.AddDroppedFull(batch->frames.size()); continue; } - // 分批提交给 USB。 + // Submit to USB in chunks. std::size_t offset = 0; while (offset < batch->frames.size()) { - // 每个分片前重新查一次停机与暂停:下面的背压等待让这个循环可能长时间 - // 驻留,不能只依赖外层的检查。暂停时丢弃剩余帧是刻意的(RNDIS 复位期间 - // 设备会丢弃所有未完成的数据包);TX 拿不到 RX 那样的强保证(它必须持续 - // 把 BPF 读空,没法真的停下),但把漏发窗口收窄到「一个分片」是免费的。 + // Recheck the stop and pause flags before every chunk: the backpressure + // wait below can keep this loop parked here for a long time, so we + // cannot rely only on the outer check. Dropping the remaining frames + // while paused is deliberate (during an RNDIS reset the device discards + // all outstanding packets); TX cannot get RX's strong guarantee (it must + // keep draining BPF and cannot really stop), but narrowing the unsent + // window to "one chunk" is free. if (stop_requested_.load(std::memory_order_acquire) || paused_.load(std::memory_order_acquire)) { counters_.tx.AddDroppedFull(batch->frames.size() - offset); @@ -251,17 +282,20 @@ void Bridge::RunTransmitExtractor() noexcept { const auto sent = data_channel_->SendFrames(slice); if (!sent) { counters_.tx.AddIoError(); - // 放弃的剩余帧必须计入丢弃 —— 曾经这里只 +1 个 io_error 就 break, - // 剩余帧不进任何计数器,统计上凭空消失。 + // Abandoned remaining frames must count as dropped -- this used to + // break with only +1 io_error, leaving the rest out of every counter, + // vanishing from the statistics entirely. counters_.tx.AddDroppedFull(batch->frames.size() - offset); TETHERKIT_WARN_TR(Msg::kCoreUsbSubmitFailed, chunk, sent.error().ToString()); break; } - // 桥接层是 TX 计数器的**唯一写者**(DirectionCounters 用 relaxed - // load+store,靠单写者才安全)。数据通道那侧只统计异步完成回调里的错误, - // 由 Snapshot() 在读取时合并进来。发出帧数与字节数直接取自 SendOutcome, - // 被跳过的非法长度帧计入 oversize 丢弃而不是「已发出」。 + // The bridge is the **only writer** of the TX counters + // (DirectionCounters uses relaxed load+store and is safe only with a + // single writer). The data channel side only counts errors from async + // completion callbacks, merged in by Snapshot() when read. Frames and + // bytes sent come straight from SendOutcome; frames skipped for illegal + // length count as oversize drops rather than "sent". if (sent->sent_frames != 0) { counters_.tx.AddBatch(sent->sent_frames, sent->sent_bytes); } @@ -270,19 +304,27 @@ void Bridge::RunTransmitExtractor() noexcept { } if (sent->consumed == 0) { - // 传输池没有空闲槽位 —— 这就是背压。**等待,不丢弃。** + // No free slot in the transfer pool -- this is backpressure. **Wait, + // do not drop.** // - // 旧实现在这里立即丢弃剩余帧,理由是「等待会让 BPF 内核缓冲堆积成 - // 不可见的内核丢包」。这个前提是错的:内核缓冲默认 4 MiB(250 Mbps - // 下约 130 ms 的弹性),而且它的溢出计数 bs_drop 每次 ReadFrames 都 - // 带回来、就显示在统计行的「内核丢包」里 —— 根本不是不可见的。 - // 真机实测的后果:一个满帧分片需要约 26 个传输槽而池里只有 4 个, - // 槽位在几百微秒内就会释放,旧代码却一微秒都不等,高负载下把 - // 30%+ 的 TX 流量丢在了这里(docs/BENCHMARKS.md「真机端到端实测」)。 + // The old implementation dropped the remaining frames here + // immediately, reasoning that "waiting would pile kernel-buffer + // backlog into invisible kernel drops". That premise was wrong: the + // kernel buffer is 4 MiB by default (about 130 ms of elasticity at + // 250 Mbps), and its overflow counter bs_drop comes back with every + // ReadFrames call and is displayed in the statistics line's "kernel + // drops" column -- nothing invisible about it. + // Measured consequence on real hardware: a full-frame chunk needs + // about 26 transfer slots while the pool only has 4; slots free up + // within a few hundred microseconds, yet the old code would not wait a + // single microsecond and dropped 30%+ of TX traffic here under load + // (docs/BENCHMARKS.md, "Real-device end-to-end measurements"). // - // 等待用有限超时:每次醒来回到循环顶部重查停机/暂停标志,停机延迟 - // 因此有上界。真正的溢出兜底仍然在内核缓冲 —— 它满了才该丢, - // 且丢在统计上可见。 + // The wait uses a bounded timeout: every wakeup returns to the top of + // the loop and rechecks the stop/pause flags, so shutdown latency + // stays bounded. The real overflow backstop is still the kernel + // buffer -- only when it is full should anything drop, and that drop + // is visible in the statistics. tx_backpressure_events_.fetch_add(1, std::memory_order_relaxed); (void)data_channel_->WaitForSendCapacity(kTxCapacityWaitMillis); continue; @@ -295,13 +337,15 @@ void Bridge::RunTransmitExtractor() noexcept { } // ============================================================================= -// 观测 +// Observability // ============================================================================= BridgeStats Bridge::Snapshot() const { DirectionSnapshot tx = tetherkit::Snapshot(counters_.tx); - // 合并数据通道在异步完成回调里累计的错误 —— 那部分由 libusb 事件线程递增, - // 刻意与桥接层的计数器分开以维持「每个计数器只有一个写者」的不变式。 + // Merge the errors the data channel accumulated in its async completion + // callbacks -- those are incremented by the libusb event thread and are + // deliberately kept apart from the bridge's counters to preserve the + // "exactly one writer per counter" invariant. tx.io_errors += data_channel_->AsyncSendErrors(); return BridgeStats{ diff --git a/src/core/runtime.cc b/src/core/runtime.cc index 0a058ab..0982e77 100644 --- a/src/core/runtime.cc +++ b/src/core/runtime.cc @@ -42,7 +42,7 @@ Runtime::~Runtime() { } // ============================================================================= -// 宿主线程接口 +// Host thread interface // ============================================================================= Status Runtime::Start() { @@ -50,12 +50,14 @@ Status Runtime::Start() { return std::unexpected(Error::Generic(Tr(Msg::kCoreRuntimeAlreadyStarted))); } - // root 检查刻意留在**同步**路径上。 + // The root check is deliberately left on the **synchronous** path. // - // 它不需要任何 I/O,而「忘了 sudo」是最常见的失败,提前同步报出来,命令行 - // 才能在返回码里体现,也不用让用户盯着一个转圈的界面等异步事件。 - // 注意 libusb 声明 RNDIS 接口**不需要** root(macOS 没有 RNDIS 内核驱动), - // 需要 root 的是 feth 创建与 /dev/bpf* 打开。 + // It needs no I/O, and "forgot to sudo" is the most common failure; reporting + // it synchronously up front lets the CLI reflect it in the exit code instead + // of making the user watch a spinner while waiting for an async event. + // Note that claiming an RNDIS interface over libusb does **not** need root + // (macOS has no RNDIS kernel driver); root is needed for creating the feth + // pair and opening /dev/bpf*. if (!net::IsRunningAsRoot()) { return std::unexpected(Error::Generic(Tr(Msg::kCoreNeedsRoot))); } @@ -81,11 +83,14 @@ void Runtime::Stop() { return; } - // 置停机标志并唤醒可能正在睡的控制线程。 + // Set the stop flag and wake the control thread in case it is sleeping. // - // 标志必须在持锁时写:控制线程的 wait_for 用它当谓词,不持锁写会有 - // 「检查完谓词、还没睡下」的丢失唤醒窗口。RequestStop() 不持锁是刻意的 - // 取舍(它要保持异步信号安全),代价是最多晚一个循环周期被看到。 + // The flag must be written while holding the lock: the control thread's + // wait_for uses it as its predicate, and writing it without the lock leaves + // a lost-wakeup window between "predicate checked" and "actually asleep". + // RequestStop() not taking the lock is a deliberate trade-off (it must stay + // async-signal-safe); the cost is that the flag may take up to one loop + // period to be observed. { const std::lock_guard guard(stop_mutex_); stop_requested_.store(true, std::memory_order_release); @@ -108,12 +113,13 @@ std::string Runtime::SystemInterfaceName() const { } // ============================================================================= -// 控制线程 +// Control thread // ============================================================================= void Runtime::RunControlThread() noexcept { - // 控制线程用 kControl 而非 kDataPath 的 QoS:它每几秒才干一点活, - // 抢性能核对它没意义,反而可能挤占三个真正的数据路径线程。 + // The control thread uses kControl rather than kDataPath QoS: it does a bit + // of work every few seconds, so fighting for performance cores is pointless + // and could crowd out the three real data-path threads. ConfigureCurrentThread("rndis-ctl", ThreadRole::kControl); if (const auto status = RunStartSequence(); !status) { @@ -124,8 +130,9 @@ void Runtime::RunControlThread() noexcept { SetRunState(RunState::kStopping); } - // 无论启动成功与否都要拆除:启动序列可能已经建了半套东西(比如 feth 建好了 - // 但 BPF 打不开),漏掉就会把网卡留在内核里。 + // Tear down whether or not startup succeeded: the start sequence may have + // built half a stack already (e.g. feth created but BPF will not open), and + // skipping this would leave the interface behind in the kernel. Teardown(); SetRunState(fatal_error_.load(std::memory_order_acquire) ? RunState::kFailed @@ -133,12 +140,12 @@ void Runtime::RunControlThread() noexcept { } Status Runtime::RunStartSequence() { - // ---- 第 1 步:libusb 上下文与事件线程 ---- + // ---- Step 1: libusb context and event thread ---- // - // (root 检查已在 Start() 里同步做过。) + // (The root check was already done synchronously in Start().) TETHERKIT_ASSIGN_OR_RETURN(usb_context_, usb::Context::Create()); - // ---- 第 2 步:发现并打开设备 ---- + // ---- Step 2: discover and open the device ---- TETHERKIT_ASSIGN_OR_RETURN(const auto candidates, usb::FindRndisDevices(*usb_context_, config_.device_filter)); if (candidates.empty()) { @@ -150,33 +157,39 @@ Status Runtime::RunStartSequence() { TETHERKIT_ASSIGN_OR_RETURN(device_, usb::Device::Open(*usb_context_, candidates.front())); RefreshSnapshot(); - // ---- 第 3 步:RNDIS 控制通道与状态机 ---- + // ---- Step 3: RNDIS control channel and state machine ---- // - // 控制通道用同步 libusb API,因此必须跑在**非事件线程**上 —— 本函数就在 - // Runtime 自己的控制线程上,满足要求。 + // The control channel uses the synchronous libusb API, so it must run on a + // **non-event thread** -- this function runs on Runtime's own control + // thread, which satisfies that. control_channel_ = std::make_unique(*device_, config_.rndis.control_timeout_millis); - // 中断通知必须走异步传输,否则控制线程会永久卡死 —— 详见 - // include/tetherkit/usb/device.h 里 UsbControlChannel 的说明。 - // 必须在状态机 Start() **之前**启动,因为初始化握手本身就要等通知。 + // Interrupt notifications must go over an async transfer or the control + // thread would wedge forever -- see the UsbControlChannel notes in + // include/tetherkit/usb/device.h. It must be started **before** the state + // machine's Start(), because the initialization handshake itself waits for + // notifications. TETHERKIT_RETURN_IF_ERROR(control_channel_->StartNotificationListener()); - // 让 RNDIS 协商用上真实的端点最大包长(影响 MaxTransferSize 的推导)。 + // Let the RNDIS negotiation use the real endpoint max packet size (it + // affects how MaxTransferSize is derived). rndis::StateMachineConfig rndis_config = config_.rndis; rndis_config.requested_mtu = config_.mtu; state_machine_ = std::make_unique(*control_channel_, *this, rndis_config); TETHERKIT_RETURN_IF_ERROR(state_machine_->Start()); - // 到这里才拿到了协商结果:设备 MAC、最终 MTU、聚合上限、对齐要求。 + // Only now do we have the negotiation results: device MAC, final MTU, + // aggregation caps, alignment requirements. const rndis::NegotiatedParameters& parameters = state_machine_->Parameters(); const rndis::DeviceInfo& info = state_machine_->Info(); - // ---- 第 4 步:创建 feth 网卡对 ---- + // ---- Step 4: create the feth interface pair ---- // - // 必须在协商之后:系统侧 MAC 要设成设备汇报的地址,MTU 要用协商结果, - // 而 MAC 又必须在接口 IFF_UP 之前设好。 + // Must happen after negotiation: the system-side MAC has to be set to the + // address the device reports, the MTU must come from the negotiation, and + // the MAC must be set before the interface goes IFF_UP. { const net::MacAddress* system_mac = nullptr; net::MacAddress adopted{}; @@ -187,35 +200,39 @@ Status Runtime::RunStartSequence() { TETHERKIT_ASSIGN_OR_RETURN(auto pair, net::FethPair::Create(parameters.mtu, system_mac)); feth_pair_ = std::make_unique(std::move(pair)); } - // 网卡一建好就刷进快照:后面任何一步失败时,GUI 也该能看到网卡名, - // 否则用户连「刚才建了哪张卡」都不知道。 + // Flush the interfaces into the snapshot as soon as they exist: if any later + // step fails, the GUI should still be able to show the interface names, + // otherwise the user cannot even tell which card was just created. RefreshSnapshot(); - // ---- 第 5 步:在**驱动侧**接口上打开 BPF ---- + // ---- Step 5: open BPF on the **driver-side** interface ---- // - // 挂驱动侧而不是系统侧,是因为主机从系统侧发出的帧在驱动侧表现为 input 方向, - // 而我们 write 进驱动侧的帧会进入系统侧的 input —— 一个描述符同时完成收发。 + // We attach on the driver side rather than the system side because frames + // the host sends from the system side appear as input on the driver side, + // while frames we write() into the driver side arrive as input on the system + // side -- one descriptor handles both directions. { net::BpfConfig bpf_config = config_.bpf; - // 单帧上限 = MTU + 以太头。注意内核 bpfwrite 的硬上限是 MTU + 18, - // 所以这里绝不能超过它。 + // Max frame = MTU + Ethernet header. Note the kernel bpfwrite hard cap is + // MTU + 18, so this must never exceed it. bpf_config.max_frame_bytes = parameters.mtu + rndis::kEthernetHeaderBytes; TETHERKIT_ASSIGN_OR_RETURN(bpf_link_, net::BpfLink::Open(feth_pair_->DriverSide().Name(), bpf_config)); } - // ---- 第 6 步:创建 USB 数据通道 ---- + // ---- Step 6: create the USB data channel ---- { usb::DataChannelConfig data_config = config_.data_channel; - // bulk IN 缓冲必须 >= 我们在 INITIALIZE_MSG 里宣称的 MaxTransferSize, - // 否则设备聚合出来的大传输会溢出/被截断。 + // The bulk IN buffer must be >= the MaxTransferSize we advertise in + // INITIALIZE_MSG, or large transfers the device aggregates will overflow + // or be truncated. data_config.rx_transfer_bytes = std::max(data_config.rx_transfer_bytes, config_.rndis.host_max_transfer_size); TETHERKIT_ASSIGN_OR_RETURN(data_channel_, usb::UsbDataChannel::Create(*device_, parameters, data_config)); } - // ---- 第 7 步:启动桥接层 ---- + // ---- Step 7: start the bridge ---- { BridgeConfig bridge_config = config_.bridge; bridge_config.max_frame_bytes = parameters.mtu + rndis::kEthernetHeaderBytes; @@ -237,19 +254,21 @@ void Runtime::RunControlLoop() { while (!stop_requested_.load(std::memory_order_acquire) && !fatal_error_.load(std::memory_order_acquire)) { - // ---- 状态机的周期性工作:保活 + 排空设备推送的消息 ---- + // ---- State machine periodic work: keep-alive + draining device pushes ---- if (const auto status = state_machine_->Poll(); !status) { RecordFatal(std::move(status).error()); break; } - // ---- 刷新快照 ---- + // ---- Refresh the snapshot ---- // - // 放在每一轮循环里而不是只在事件发生时刷:统计计数器是持续变化的, - // GUI 要靠两次快照做差算速率。循环周期上限 250 ms,足够 2 Hz 的界面刷新。 + // Done every loop iteration rather than only on events: the statistics + // counters change continuously, and the GUI subtracts two snapshots to + // compute rates. The loop period is capped at 250 ms, plenty for a 2 Hz + // UI refresh. RefreshSnapshot(); - // ---- 周期性统计 ---- + // ---- Periodic statistics ---- const Nanos now = MonotonicNanos(); if (stats_interval_nanos != 0 && now - last_stats_nanos >= stats_interval_nanos) { const BridgeStats current = bridge_->Snapshot(); @@ -260,10 +279,11 @@ void Runtime::RunControlLoop() { last_stats_nanos = now; } - // ---- 睡到下一个该醒的时刻 ---- + // ---- Sleep until the next moment we should wake ---- // - // 取「下次保活」与「下次统计」两个期限里更近的那个,避免无谓的唤醒。 - // 上限 250 ms 是为了让只写原子的 RequestStop() 也能被及时看到。 + // Take the nearer of "next keep-alive" and "next statistics" deadlines to + // avoid pointless wakeups. The 250 ms cap exists so that RequestStop(), + // which only writes an atomic, is observed promptly. std::uint32_t sleep_millis = std::min(state_machine_->MillisUntilNextPoll(), 250); if (stats_interval_nanos != 0) { @@ -272,11 +292,12 @@ void Runtime::RunControlLoop() { sleep_millis = std::min(sleep_millis, static_cast(remaining / kNanosPerMilli)); } - // 至少睡 10 ms:避免在保活刚好到期的边界上忙转。 + // Always sleep at least 10 ms: avoids busy-spinning right at the + // keep-alive deadline boundary. sleep_millis = std::max(sleep_millis, 10); - // 用条件变量而不是 sleep_for:Stop() 能立刻把我们叫醒,用户按下「停止」 - // 不用等最多 250 ms 才有反应。 + // A condition variable instead of sleep_for: Stop() wakes us immediately, + // so pressing "Stop" responds right away instead of after up to 250 ms. std::unique_lock lock(stop_mutex_); stop_condition_.wait_for(lock, std::chrono::milliseconds(sleep_millis), [this] { return stop_requested_.load(std::memory_order_acquire); }); @@ -304,54 +325,61 @@ void Runtime::PrintNextSteps() const { } // ============================================================================= -// 停机 +// Teardown // ============================================================================= void Runtime::Teardown() { TETHERKIT_INFO_TR(Msg::kCoreStopping); - // 严格按启动顺序的**逆序**拆除。 + // Teardown in the exact **reverse** of the start-up order. // - // 1. 先停桥接层:它内部会 join 两个数据路径线程,然后调 - // DataChannel::Shutdown() 等在飞 USB 传输全部回收。这一步必须在销毁 - // device_ 之前完成 —— libusb_close 不会帮我们回收在飞 transfer, - // 先关句柄再释放 transfer 就是 use-after-free。 + // 1. Stop the bridge first: it joins its two data-path threads internally, + // then calls DataChannel::Shutdown() to wait for all in-flight USB + // transfers to be reclaimed. This must finish before device_ is + // destroyed -- libusb_close will not reclaim in-flight transfers for us, + // so closing the handle before freeing them is a use-after-free. if (bridge_ != nullptr) { bridge_->Stop(); bridge_.reset(); } data_channel_.reset(); - // 2. 关 BPF(此时已经没人在读写它了)。 + // 2. Close BPF (nobody is reading or writing it by now). bpf_link_.reset(); - // 3. 销毁 feth 网卡对。内核的 feth_clone_destroy 会自动先解绑 peer。 + // 3. Destroy the feth interface pair. The kernel's feth_clone_destroy + // unbinds the peer first, automatically. feth_pair_.reset(); - // 4. 让设备退出 RNDIS:先 SET filter = 0 让它停止发数据,再发 HALT。 - // 必须在关闭 USB 句柄之前做 —— 否则设备会一直以为主机还在, - // 下次插上时状态不干净。 + // 4. Take the device out of RNDIS: first SET filter = 0 so it stops sending + // data, then send HALT. This must happen before the USB handle is closed + // -- otherwise the device keeps believing the host is still there and the + // next plug-in starts unclean. // - // 这一步会发同步控制消息,因此必须在控制线程上 —— 本函数正是。 + // This step sends a synchronous control message, so it must run on the + // control thread -- which is where we are. if (state_machine_ != nullptr) { state_machine_->Stop(); state_machine_.reset(); } - // 先停中断监听(它会等在飞的异步 transfer 回收完),再销毁通道对象。 + // Stop the interrupt listener first (it waits for in-flight async transfers + // to be reclaimed), then destroy the channel object. if (control_channel_ != nullptr) { control_channel_->StopNotificationListener(); control_channel_.reset(); } - // 5. 释放接口并关闭设备句柄。 + // 5. Release the interfaces and close the device handle. device_.reset(); - // 6. 最后停 libusb 事件线程并释放上下文。 + // 6. Finally stop the libusb event thread and release the context. usb_context_.reset(); - // 拆完再刷一次:网卡名、统计都该归零,GUI 不该继续显示已经不存在的网卡。 - // link_up 由事件维护、RefreshSnapshot 不会动它,所以在这里显式清掉 —— - // 停机后界面上还亮着「链路已连接」会误导人。 + // Refresh once more after teardown: interface names and statistics should be + // zeroed, and the GUI should not keep showing an interface that no longer + // exists. link_up is maintained by events and RefreshSnapshot does not touch + // it, so clear it explicitly here -- leaving "link connected" lit after + // shutdown would be misleading. { const std::lock_guard guard(snapshot_mutex_); snapshot_.link_up = false; @@ -361,12 +389,12 @@ void Runtime::Teardown() { } // ============================================================================= -// 快照与事件 +// Snapshot and events // ============================================================================= void Runtime::RefreshSnapshot() { - // 先在锁外把各组件的状况读出来 —— 这些读取会走 ioctl / 原子加载, - // 不该占着 snapshot_mutex_。 + // Read each component's state outside the lock first -- those reads go + // through ioctl / atomic loads and should not hold snapshot_mutex_. RuntimeSnapshot fresh; if (device_ != nullptr) { fresh.device_description = std::string{device_->Describe()}; @@ -386,7 +414,8 @@ void Runtime::RefreshSnapshot() { } const std::lock_guard guard(snapshot_mutex_); - // run_state、link_up 与 fatal_message 由各自的迁移点维护,这里不能覆盖。 + // run_state, link_up and fatal_message are maintained by their own + // transition points; do not overwrite them here. fresh.run_state = snapshot_.run_state; fresh.link_up = snapshot_.link_up; fresh.fatal_message = snapshot_.fatal_message; @@ -415,7 +444,8 @@ void Runtime::RecordFatal(const Error& error) { { const std::lock_guard guard(snapshot_mutex_); - // 只记**第一个**致命错误:后续的多半是它引发的连锁反应,覆盖掉反而丢了根因。 + // Record only the **first** fatal error: later ones are mostly knock-on + // effects of it, and overwriting would lose the root cause. if (snapshot_.fatal_message.empty()) { snapshot_.fatal_message = message; } @@ -431,11 +461,12 @@ void Runtime::Emit(const RuntimeEvent& event) const { } // ============================================================================= -// StateMachineObserver(均在控制线程上被调用) +// StateMachineObserver (all called on the control thread) // ============================================================================= void Runtime::OnStateChanged(rndis::State from, rndis::State to) { - // 状态机自己已经打了日志,这里不重复,只把迁移转发给宿主。 + // The state machine has already logged this; do not repeat it here, just + // forward the transition to the host. Emit(RuntimeEvent{.kind = RuntimeEvent::Kind::kRndisState, .a = static_cast(from), .b = static_cast(to), @@ -462,8 +493,9 @@ void Runtime::OnLinkStateChanged(bool connected) { snapshot_.link_up = connected; } - // 链路 down 时暂停数据搬运。继续往断开的链路发帧只是浪费,而且会把队列填满 - // 导致真正恢复时先送出一堆过期的帧。 + // Pause data movement while the link is down. Pushing frames onto a broken + // link is pure waste and would fill the queue, so on recovery a burst of + // stale frames goes out first. if (bridge_ != nullptr) { bridge_->SetPaused(!connected); } @@ -475,8 +507,10 @@ void Runtime::OnDeviceReset(bool addressing_lost) { TETHERKIT_WARN_TR(Msg::kCoreDeviceReset, Text(addressing_lost ? Msg::kCoreAddressingLost : Msg::kCoreAddressingKept)); - // 复位期间设备丢弃了所有未完成的数据包。短暂暂停让状态机把包过滤重放完, - // 避免在设备重建内部状态的窗口里继续灌数据。 + // The device discarded all outstanding packets during the reset. Pause + // briefly so the state machine can finish replaying the packet filter, + // avoiding pumping data into the window where the device rebuilds its + // internal state. if (bridge_ != nullptr) { bridge_->SetPaused(true); bridge_->SetPaused(false); @@ -486,7 +520,8 @@ void Runtime::OnDeviceReset(bool addressing_lost) { } void Runtime::OnFatalError(const Error& error) { - // WithContext 是 &&-限定的,而这里拿到的是 const 引用,必须先拷一份。 + // WithContext is &&-qualified and what we hold here is a const reference, + // so we must copy first. Error annotated = error; RecordFatal(std::move(annotated).WithContext(Tr(Msg::kCoreLinkUnrecoverable))); } diff --git a/src/net/CMakeLists.txt b/src/net/CMakeLists.txt index 704e629..e76b692 100644 --- a/src/net/CMakeLists.txt +++ b/src/net/CMakeLists.txt @@ -1,8 +1,10 @@ -# tk_net —— 虚拟网卡与链路层 +# tk_net -- virtual interface and link layer # -# 只依赖 macOS 系统调用(ioctl / BPF),不依赖 libusb 也不依赖 RNDIS 协议层。 -# LinkBackend 是本模块的对外抽象;BpfLink 是生产实现,LoopbackLink 是纯内存实现 -# —— 后者让桥接层的端到端测试与吞吐基准能在没有 root、没有真实接口的机器上跑。 +# Only depends on macOS syscalls (ioctl / BPF); neither libusb nor the RNDIS +# protocol layer. LinkBackend is the module's public abstraction; BpfLink is the +# production implementation and LoopbackLink is a pure-memory implementation -- +# the latter lets the bridge layer's end-to-end tests and throughput benchmarks +# run on machines without root and without a real interface. add_library( tk_net STATIC diff --git a/src/net/bpf_link.cc b/src/net/bpf_link.cc index 77f2e63..cd30eb9 100644 --- a/src/net/bpf_link.cc +++ b/src/net/bpf_link.cc @@ -20,21 +20,23 @@ namespace tetherkit::net { namespace { -/// 遍历 /dev/bpf%d 的上限。 +/// The upper limit for scanning /dev/bpf%d. /// -/// 内核在打开当前最后一个节点时会按需再造一个(bpfopen 里 -/// `if (minor(dev) == nbpfilter - 1) bpf_make_dev_t(...)`),上限由 sysctl -/// debug.bpf_maxdevices 控制(本机 256)。这里取 256 与之对齐。 +/// The kernel recreates a node on demand when the current last node is opened +/// (`if (minor(dev) == nbpfilter - 1) bpf_make_dev_t(...)` inside bpfopen); +/// the ceiling is controlled by the sysctl debug.bpf_maxdevices (256 on this +/// machine). This constant takes 256 to match. constexpr int kMaxBpfDeviceIndex = 256; -/// 批量写时一次 write() 的字节上限。 +/// The per-write() byte ceiling for batched writes. /// -/// 内核允许的绝对上限是 BPF_WRITE_MAX = 16 MiB,但没必要用那么大: -/// 256 KiB 已能装下上百个满帧,单次系统调用的固定开销早就被摊薄了, -/// 而更大的组装缓冲只会恶化 L2 缓存命中。 +/// The kernel's absolute ceiling is BPF_WRITE_MAX = 16 MiB, but that large is +/// unnecessary: 256 KiB already fits over a hundred full frames and the fixed +/// per-syscall overhead has long since been amortized, while a bigger assembly +/// buffer only hurts L2 cache hits. constexpr std::size_t kMaxBatchWriteBytes = std::size_t{256} * 1024; -/// 一次 ioctl,失败时把 errno 与调用名包进 Error。 +/// One ioctl; on failure wraps errno and the call name into an Error. [[nodiscard]] Status CallIoctl(int fd, unsigned long request, void* argument, std::string_view what) { if (::ioctl(fd, request, argument) < 0) { @@ -43,11 +45,13 @@ constexpr std::size_t kMaxBatchWriteBytes = std::size_t{256} * 1024; return Ok(); } -/// 尝试一个**可选**的 ioctl:失败不算错误,只返回是否成功。 +/// Attempts an **optional** ioctl: a failure is not an error, just a false +/// return. [[nodiscard]] bool TryOptionalIoctl(int fd, unsigned long request, void* argument, std::string_view what) { if (::ioctl(fd, request, argument) < 0) { - // ENOTTY / EINVAL 表示当前 macOS 版本不支持这个私有 ioctl,属于预期情况。 + // ENOTTY / EINVAL means this macOS version does not know the private ioctl, + // an expected case. TETHERKIT_DEBUG_TR(Msg::kNetOptionalIoctlUnavailable, what, errno); return false; } @@ -82,11 +86,12 @@ Result> BpfLink::Open(std::string_view interface_name, link->max_frame_bytes_ = config.max_frame_bytes; // --------------------------------------------------------------------------- - // 1. 打开 /dev/bpf%d + // 1. Open /dev/bpf%d // - // macOS **没有** /dev/bpf 克隆节点(实测 ls /dev/bpf → No such file), - // 必须逐个编号尝试:EBUSY 表示该 minor 已被别的进程占用,试下一个; - // ENOENT 表示已到内核当前造出的节点上限。 + // macOS has **no** /dev/bpf clone node (measured: ls /dev/bpf -> No such + // file), so the numbered nodes must be tried one by one: EBUSY means someone + // else holds that minor, try the next; ENOENT means the kernel-made node + // ceiling has been reached. // --------------------------------------------------------------------------- int last_errno = 0; for (int index = 0; index < kMaxBpfDeviceIndex; ++index) { @@ -99,16 +104,16 @@ Result> BpfLink::Open(std::string_view interface_name, } last_errno = errno; if (last_errno == EBUSY) { - continue; // 被别的抓包程序占了,换下一个 + continue; // held by another capture program, try the next } if (last_errno == ENOENT) { - break; // 已经到上限,不会再有更大编号的节点 + break; // ceiling reached; there will be no node with a higher number } if (last_errno == EACCES || last_errno == EPERM) { return std::unexpected( Error::FromErrno(last_errno, Tr(Msg::kNetBpfOpenDenied, path))); } - // 其它 errno:继续试下一个编号,最后统一报错。 + // Any other errno: keep trying the next number, report at the end. } if (link->fd_ < 0) { @@ -117,11 +122,12 @@ Result> BpfLink::Open(std::string_view interface_name, } // --------------------------------------------------------------------------- - // 2. BIOCSBLEN —— **必须在 BIOCSETIF 之前** + // 2. BIOCSBLEN -- **must come before BIOCSETIF** // - // bpf.c 里:`if (d->bd_bif != 0 || (d->bd_flags & BPF_DETACHING)) return EINVAL`。 - // 而且超限时**不报错**,静默截到 BPF_BUFSIZE_CAP 并通过 _IOWR 把实际生效值 - // 写回参数 —— 所以后面每次 read() 都必须用这个写回值当长度。 + // In bpf.c: `if (d->bd_bif != 0 || (d->bd_flags & BPF_DETACHING)) return + // EINVAL`. And an over-limit value does **not** error; it is silently clamped + // to BPF_BUFSIZE_CAP and the effective value is written back through _IOWR -- + // so every read() below must use this written-back value as the length. // --------------------------------------------------------------------------- auto buffer_bytes = static_cast(config.kernel_buffer_bytes); TETHERKIT_RETURN_IF_ERROR( @@ -132,21 +138,25 @@ Result> BpfLink::Open(std::string_view interface_name, } // --------------------------------------------------------------------------- - // 3. BIOCSHDRCMPLT = 1 —— **强制**,且必须在 BIOCSBATCHWRITE 之前 + // 3. BIOCSHDRCMPLT = 1 -- **mandatory**, and must come before BIOCSBATCHWRITE // - // hdrcmplt=0 时 bpfwrite 会剥掉前 14 字节重建帧头(源 MAC 被驱动改写); - // =1 才走 DLIL_OUTPUT_FLAGS_RAW 原样透传。批量写还硬性要求它是 1。 + // With hdrcmplt=0, bpfwrite strips the first 14 bytes and rebuilds the frame + // header (the source MAC gets rewritten by the driver); =1 takes the + // DLIL_OUTPUT_FLAGS_RAW pass-through path instead. Batched writes + // additionally hard-require it to be 1. // - // ⚠️ 而且在 feth 上后果比「帧头被改写」严重得多:**不设这个 ioctl,write() - // 直接返回 ENXIO**(if_fake 的输出路径处理不了 AF_UNSPEC 那条重建分支)。 - // 对照实验见 AGENTS.md 第 7 节第 14 条。排查 ENXIO 时别去怀疑接口名。 + // And on feth the consequence is far worse than "the header gets + // rewritten": **without this ioctl, write() fails outright with ENXIO** + // (if_fake's output path cannot handle the AF_UNSPEC rebuild branch). + // Controlled experiment in AGENTS.md section 7, item 14. When debugging + // ENXIO, do not suspect the interface name. // --------------------------------------------------------------------------- int header_complete = 1; TETHERKIT_RETURN_IF_ERROR( CallIoctl(link->fd_, BIOCSHDRCMPLT, &header_complete, "ioctl(BIOCSHDRCMPLT)")); // --------------------------------------------------------------------------- - // 4. BIOCSETIF —— 绑定到接口 + // 4. BIOCSETIF -- bind to the interface // --------------------------------------------------------------------------- ::ifreq bind_request{}; std::memcpy(bind_request.ifr_name, interface_name.data(), interface_name.size()); @@ -163,10 +173,11 @@ Result> BpfLink::Open(std::string_view interface_name, } // --------------------------------------------------------------------------- - // 5. BIOCGDLT —— 校验数据链路类型 + // 5. BIOCGDLT -- validate the data-link type // - // feth 的 bpfattach 用的是 DLT_EN10MB,所以这里一定是 1;校验它是为了防止 - // 有人误把 BPF 绑到别的接口类型上,那样帧格式假设就全错了。 + // feth's bpfattach uses DLT_EN10MB, so this is always 1; the check guards + // against someone accidentally binding BPF to some other interface type, + // where every frame-format assumption would be wrong. // --------------------------------------------------------------------------- unsigned int data_link_type = 0; TETHERKIT_RETURN_IF_ERROR( @@ -177,31 +188,35 @@ Result> BpfLink::Open(std::string_view interface_name, } // --------------------------------------------------------------------------- - // 6. BIOCIMMEDIATE = 1 —— 立即投递 + // 6. BIOCIMMEDIATE = 1 -- immediate delivery // - // 不开的话 read() 只在缓冲**填满**时才返回;用 4 MiB 缓冲的话延迟灾难性地高。 - // 开了之后每来一包就唤醒,read() 醒来时一次性交付期间累积的全部包 —— - // 低速低延迟、高速自动大批量,行为类似 NAPI。 + // Without it, read() returns only when the buffer **fills up**; with a 4 MiB + // buffer the latency would be catastrophically high. With it, the reader + // wakes on every packet and each wake delivers everything that piled up in + // the meantime -- low traffic gives low latency, high traffic gives large + // batches automatically, NAPI-like behavior. // --------------------------------------------------------------------------- unsigned int immediate = 1; TETHERKIT_RETURN_IF_ERROR( CallIoctl(link->fd_, BIOCIMMEDIATE, &immediate, "ioctl(BIOCIMMEDIATE)")); // --------------------------------------------------------------------------- - // 7. BIOCSSEESENT = 0 —— 只抓 input 方向 + // 7. BIOCSSEESENT = 0 -- capture the input direction only // - // 这一步是**防回环的关键**:我们 write 进去的帧在本接口上是 output 方向, - // SEESENT=0 会把它们滤掉,只留下主机从 peer 侧发来的 input 帧。 + // This step is **the anti-loopback key**: the frames we write appear on this + // interface in the output direction, and SEESENT=0 filters them out, leaving + // only the input frames the host sends from the peer side. // --------------------------------------------------------------------------- unsigned int see_sent = 0; TETHERKIT_RETURN_IF_ERROR( CallIoctl(link->fd_, BIOCSSEESENT, &see_sent, "ioctl(BIOCSSEESENT)")); // --------------------------------------------------------------------------- - // 8. BIOCSRTIMEOUT —— 读超时,用于响应停机 + // 8. BIOCSRTIMEOUT -- read timeout, used to answer shutdown // - // 内核存的是 tvtohz(tv) - 1 个 tick,本机 hz=100 → 分辨率 10 ms; - // 传 {0,0} 会变成**永久阻塞**,因此这里必须传非零值。 + // The kernel stores tvtohz(tv) - 1 ticks; on this machine hz=100 -> a 10 ms + // resolution. Passing {0,0} becomes **a permanent block**, so a non-zero + // value is mandatory here. // --------------------------------------------------------------------------- ::timeval read_timeout{}; read_timeout.tv_sec = config.read_timeout_millis / 1000; @@ -210,12 +225,15 @@ Result> BpfLink::Open(std::string_view interface_name, TETHERKIT_RETURN_IF_ERROR( CallIoctl(link->fd_, BIOCSRTIMEOUT, &read_timeout, "ioctl(BIOCSRTIMEOUT)")); - // 刻意**不设** BIOCPROMISC:feth 的 feth_output_common 无条件把帧投给 peer - // 并 tap,不做任何 MAC 过滤,能否读到只由方向决定。设 promisc 只会多一个 - // IFF_PROMISC 引用计数和一个内核事件,纯属浪费。 + // BIOCPROMISC is deliberately **not** set: feth's feth_output_common hands + // every frame to the peer and taps it unconditionally, with no MAC filtering + // whatsoever -- whether a frame is readable depends only on direction. Setting + // promisc would only add one IFF_PROMISC reference count and one kernel + // event; pure waste. // --------------------------------------------------------------------------- - // 9. 可选优化:批量写与关闭时间戳(都做特性探测) + // 9. Optional optimizations: batched writes and disabled timestamps + // (both feature-probed) // --------------------------------------------------------------------------- if (config.try_batch_write) { int enable = 1; @@ -229,7 +247,7 @@ Result> BpfLink::Open(std::string_view interface_name, } // --------------------------------------------------------------------------- - // 10. 分配缓冲 + // 10. Allocate buffers // --------------------------------------------------------------------------- link->read_buffer_.resize(link->kernel_buffer_bytes_); link->read_frames_.reserve(config.max_frames_per_batch); @@ -258,38 +276,44 @@ Result BpfLink::QueryKernelStats() const { Result BpfLink::ReadFrames() { read_frames_.clear(); - // 所有提前返回都必须带上 last_kernel_drops_ 而不是默认的 0:kernel_drops 是 - // **累计**计数,消费方拿相邻两次做差分。空闲时读超时每 200 ms 就走一次这里, - // 报 0 会把已发生的丢包「清零」,下一个有流量的采样又把全量当成新增重报, - // 中间那个采样的差分还会在无符号数上下溢。 + // Every early return must carry last_kernel_drops_ rather than the default 0: + // kernel_drops is a **cumulative** count, and consumers take differences + // between adjacent samples. When idle, the read timeout comes through here + // every 200 ms; reporting 0 would "zero out" drops that already happened, the + // next sample with traffic would re-report the full count as new drops, and + // the interim sample's difference would underflow on the unsigned type. if (interrupted_.load(std::memory_order_acquire)) { return ReadBatch{.kernel_drops = last_kernel_drops_}; } - // read() 的长度**必须精确等于**内核的 bd_bufsize,否则 bpfread 一开头就 - // `if (uio_resid(uio) != d->bd_bufsize) return EINVAL`。 + // The read() length **must exactly equal** the kernel's bd_bufsize, otherwise + // bpfread fails at the top with `if (uio_resid(uio) != d->bd_bufsize) return + // EINVAL`. const ssize_t received = ::read(fd_, read_buffer_.data(), read_buffer_.size()); if (received < 0) { if (errno == EINTR || errno == EAGAIN) { - // 被信号打断或超时无数据,交给调用方继续循环 + // Interrupted by a signal or timed out with no data; let the caller keep + // looping. return ReadBatch{.kernel_drops = last_kernel_drops_}; } return std::unexpected(Error::FromErrno(0, Tr(Msg::kNetBpfReadFailed, device_path_))); } if (received == 0) { - return ReadBatch{.kernel_drops = last_kernel_drops_}; // 读超时到期且期间无包 + return ReadBatch{.kernel_drops = last_kernel_drops_}; // read timeout elapsed with no packets } // --------------------------------------------------------------------------- - // 遍历 BPF 记录 + // Walk the BPF records // - // 记录布局:[bpf_hdr(bh_hdrlen 字节,含对齐填充)][帧数据(bh_caplen 字节)] - // 下一条记录的偏移 = BPF_WORDALIGN(bh_hdrlen + bh_caplen)。 + // Record layout: [bpf_hdr (bh_hdrlen bytes, incl. alignment padding)] + // [frame data (bh_caplen bytes)] + // Next record's offset = BPF_WORDALIGN(bh_hdrlen + bh_caplen). // - // ★ 必须用记录里的 bh_hdrlen,不能用 sizeof(struct bpf_hdr)。★ - // LP64 下 sizeof 是 20(18 字节内容 + 2 字节编译器填充),而内核对 - // DLT_EN10MB 写入的 bh_hdrlen 是 18(SIZEOF_BPF_HDR=18, - // bif_hdrlen = BPF_WORDALIGN(14 + 18) - 14 = 18)。用 20 会立刻错位。 + // The record's bh_hdrlen must be used, never sizeof(struct bpf_hdr). + // On LP64, sizeof is 20 (18 bytes of content + 2 bytes of compiler + // padding), while the kernel writes bh_hdrlen = 18 for DLT_EN10MB + // (SIZEOF_BPF_HDR=18, bif_hdrlen = BPF_WORDALIGN(14 + 18) - 14 = 18). + // Using 20 goes out of step immediately. // --------------------------------------------------------------------------- const auto total = static_cast(received); std::size_t offset = 0; @@ -297,7 +321,8 @@ Result BpfLink::ReadFrames() { while (offset + kBpfHeaderMinBytes <= total) { const std::byte* record = read_buffer_.data() + offset; - // 逐字段读取而非结构体映射:记录起始只保证 4 字节对齐。 + // Field-by-field reads instead of a struct map: a record's start is only + // guaranteed 4-byte aligned. const std::uint32_t capture_length = LoadLe32(record + offsetof(::bpf_hdr, bh_caplen)); const std::uint32_t original_length = LoadLe32(record + offsetof(::bpf_hdr, bh_datalen)); const std::uint32_t header_length = LoadLe16(record + offsetof(::bpf_hdr, bh_hdrlen)); @@ -309,13 +334,16 @@ Result BpfLink::ReadFrames() { const std::size_t record_bytes = static_cast(header_length) + capture_length; if (offset + record_bytes > total) [[unlikely]] { - // 记录被截断:正常情况下不该发生(内核不会写出跨越缓冲末尾的记录)。 + // Record truncated: should never happen in normal operation (the kernel + // does not write records that straddle the buffer end). TETHERKIT_WARN_TR(Msg::kNetBpfRecordOutOfBounds, offset, record_bytes, total); break; } - // 只有完整捕获的帧才转发。capture < original 说明装了过滤器且截断了, - // 转发一个残帧到 USB 只会让对端困惑。我们不装过滤器,所以这里不该触发。 + // Only fully captured frames are forwarded. capture < original means a + // filter is installed and truncated the frame; forwarding a partial frame + // to USB would only confuse the peer. We install no filter, so this branch + // should not trigger. if (capture_length == original_length && capture_length >= kMinEthernetFrameBytes && capture_length <= max_frame_bytes_) [[likely]] { read_frames_.push_back(FrameView{.data = record + header_length, .length = capture_length}); @@ -327,15 +355,18 @@ Result BpfLink::ReadFrames() { offset += BPF_WORDALIGN(record_bytes); if (read_frames_.size() >= read_frames_.capacity()) { - // 帧数组满了。剩余记录本次不处理 —— 数据仍在内核缓冲里?不,已经 copyout - // 了,会丢。因此 max_frames_per_batch 必须配得足够大。这里出警告而非静默。 + // The frame array is full. The remaining records are skipped this round + // -- is the data still in the kernel buffer? No: it has already been + // copyout-ed and would be lost. max_frames_per_batch must therefore be + // sized generously enough. Warn here rather than fail silently. TETHERKIT_WARN_TR(Msg::kNetBpfBatchFrameLimit, read_frames_.capacity(), total - offset); break; } } - // BIOCGSTATS 偶发失败时沿用上次的累计值 —— 报 0 会让消费方的差分下溢, - // 见 last_kernel_drops_ 的说明。 + // When BIOCGSTATS fails sporadically, keep the previous cumulative value -- + // reporting 0 would make the consumer's difference underflow; see the note + // on last_kernel_drops_. if (const auto stats = QueryKernelStats()) { last_kernel_drops_ = stats->dropped; } @@ -364,8 +395,9 @@ Result BpfLink::WriteFramesIndividually(FrameBatch frames) { if (errno == EINTR) { continue; } - // ENOBUFS 是暂时性的(接口发送队列满),不该当致命错误;调用方看 - // frames_written < frames.size() 就知道有没写完。 + // ENOBUFS is transient (the interface's send queue is full) and must not + // be treated as fatal; the caller sees frames_written < frames.size() + // and knows not everything was written. if (errno == ENOBUFS || errno == EAGAIN) { break; } @@ -379,22 +411,24 @@ Result BpfLink::WriteFramesIndividually(FrameBatch frames) { } Result BpfLink::WriteFramesBatched(FrameBatch frames) { - // 批量写的缓冲布局与读取**完全对称**:连续的 - // [bpf_hdr(bh_hdrlen 字节)][帧数据(bh_caplen 字节)] - // 每条按 BPF_WORDALIGN(bh_hdrlen + bh_caplen) 对齐。 + // The batched-write buffer layout is **fully symmetric** with the read side: + // consecutive + // [bpf_hdr (bh_hdrlen bytes)][frame data (bh_caplen bytes)] + // records, each aligned to BPF_WORDALIGN(bh_hdrlen + bh_caplen). // - // 内核校验:bh_hdrlen >= 18、bh_caplen == bh_datalen、bh_hdrlen <= 剩余长度。 - // 时间戳字段被忽略,不必填。 + // Kernel checks: bh_hdrlen >= 18, bh_caplen == bh_datalen, bh_hdrlen <= + // remaining length. The timestamp field is ignored and need not be filled in. // - // 这里 bh_hdrlen 取 18(kBpfHeaderMinBytes)而不是 sizeof(struct bpf_hdr)=20: - // 两者都合法(超过 18 的部分被内核跳过),但 18 是最紧凑的打包, - // 也与内核在读方向写出的值一致。 + // Here bh_hdrlen takes 18 (kBpfHeaderMinBytes) rather than sizeof(struct + // bpf_hdr)=20: both are legal (the kernel skips anything past 18), but 18 is + // the most compact packing and matches the value the kernel writes on the + // read side. WriteResult result; std::size_t cursor = 0; std::uint32_t pending_frames = 0; std::uint64_t pending_bytes = 0; - // 把已组装的内容一次性写出。 + // Write out whatever has been assembled, in one shot. const auto flush = [&]() -> Status { if (cursor == 0) { return Ok(); @@ -402,7 +436,8 @@ Result BpfLink::WriteFramesBatched(FrameBatch frames) { const ssize_t written = ::write(fd_, write_buffer_.data(), cursor); if (written < 0) { if (errno == ENOBUFS || errno == EAGAIN || errno == EINTR) { - // 暂时性失败:这一批没发出去,如实反映在返回值里。 + // Transient failure: this batch went nowhere, mirrored faithfully in + // the return value. cursor = 0; pending_frames = 0; pending_bytes = 0; @@ -430,14 +465,15 @@ Result BpfLink::WriteFramesBatched(FrameBatch frames) { if (cursor + aligned_bytes > write_buffer_.size()) { TETHERKIT_RETURN_IF_ERROR(flush()); if (aligned_bytes > write_buffer_.size()) [[unlikely]] { - // 单帧就超过整个组装缓冲:配置错误,跳过并计数。 + // A single frame exceeds the whole assembly buffer: a configuration + // error; skip and count. ++result.frames_skipped; continue; } } std::byte* record = write_buffer_.data() + cursor; - // 时间戳字段内核忽略,清零即可。 + // The kernel ignores the timestamp field; zeroing it is enough. std::memset(record, 0, kBpfHeaderMinBytes); StoreLe32(record + offsetof(::bpf_hdr, bh_caplen), frame.length); StoreLe32(record + offsetof(::bpf_hdr, bh_datalen), frame.length); @@ -445,7 +481,8 @@ Result BpfLink::WriteFramesBatched(FrameBatch frames) { static_cast(kBpfHeaderMinBytes)); std::memcpy(record + kBpfHeaderMinBytes, frame.data, frame.length); - // 对齐填充清零,避免把上一批的残留数据交给内核。 + // Zero the alignment padding too, so leftovers from the previous batch are + // never handed to the kernel. if (aligned_bytes > record_bytes) { std::memset(record + record_bytes, 0, aligned_bytes - record_bytes); } diff --git a/src/net/feth_device.cc b/src/net/feth_device.cc index a51fb7b..ca8f7c2 100644 --- a/src/net/feth_device.cc +++ b/src/net/feth_device.cc @@ -22,12 +22,14 @@ namespace tetherkit::net { namespace { -/// 宿主安装的接口登记回调。用原子而非互斥锁:读发生在每次创建/销毁, -/// 而销毁路径在析构里、必须 noexcept,加锁会引入抛异常的可能。 +/// The host-installed interface registry callback. An atomic rather than a +/// mutex: reads happen on every create/destroy, and the destroy path runs in a +/// destructor that must be noexcept -- taking a lock would introduce the +/// possibility of throwing. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::atomic g_interface_registry{nullptr}; -/// 通知登记回调。回调未安装时是空操作。 +/// Notifies the registry callback. A no-op when no callback is installed. void NotifyRegistry(std::string_view name, bool created) noexcept { if (const InterfaceRegistry registry = g_interface_registry.load(std::memory_order_acquire); registry != nullptr) { @@ -35,10 +37,11 @@ void NotifyRegistry(std::string_view name, bool created) noexcept { } } -/// 一个只用来发 ioctl 的临时套接字。 +/// A temporary socket used only to issue ioctls. /// -/// 接口相关的 ioctl 需要一个 socket 作为句柄;用 AF_INET/SOCK_DGRAM 是惯例 -/// (ifconfig 也是这么做的),并不真的收发数据。 +/// Interface-related ioctls need a socket as the handle; AF_INET/SOCK_DGRAM is +/// the convention (ifconfig does the same) and no data is actually sent or +/// received over it. class IoctlSocket { public: [[nodiscard]] static Result Open() { @@ -66,7 +69,7 @@ class IoctlSocket { [[nodiscard]] int Fd() const noexcept { return fd_; } - /// 发一次 ioctl,失败时把 errno 与调用名包进 Error。 + /// Issues one ioctl; on failure wraps errno and the call name into an Error. [[nodiscard]] Status Call(unsigned long request, void* argument, std::string_view what) const { if (::ioctl(fd_, request, argument) < 0) { return std::unexpected(Error::FromErrno(0, std::string{what})); @@ -87,7 +90,7 @@ class IoctlSocket { int fd_ = -1; }; -/// 填一个只带接口名的 ifreq。 +/// Fills an ifreq carrying only the interface name. [[nodiscard]] Result<::ifreq> MakeIfreq(std::string_view name) { if (name.size() >= kInterfaceNameCapacity) { return std::unexpected(Error::Generic( @@ -98,7 +101,7 @@ class IoctlSocket { return request; } -/// 读一个 32 位整型 sysctl。 +/// Reads a 32-bit integer sysctl. [[nodiscard]] Result ReadInt32Sysctl(const char* name) { std::int32_t value = 0; std::size_t size = sizeof(value); @@ -108,7 +111,7 @@ class IoctlSocket { return value; } -/// 对 feth 发一次驱动私有 ioctl。 +/// Issues one driver-private ioctl against a feth interface. [[nodiscard]] Status CallFethDriverIoctl(const IoctlSocket& socket, std::string_view interface_name, unsigned long ioctl_request, unsigned long command, FethRequest& payload, std::string_view what) { @@ -119,7 +122,8 @@ class IoctlSocket { IfDrv driver{}; std::memcpy(driver.ifd_name, interface_name.data(), interface_name.size()); driver.ifd_cmd = command; - // 内核校验 ifd_len >= sizeof(struct if_fake_request),短了直接 EINVAL。 + // The kernel checks ifd_len >= sizeof(struct if_fake_request); shorter than + // that fails outright with EINVAL. driver.ifd_len = sizeof(payload); driver.ifd_data = &payload; @@ -151,7 +155,8 @@ Status VerifyFethSysctls() { for (const RequiredFethSysctl& entry : kRequiredFethSysctls) { const auto value = ReadInt32Sysctl(entry.name); if (!value) { - // 某些 sysctl 在特定 macOS 版本上可能不存在;缺失不算错误,只记一条。 + // Some sysctls may be missing on particular macOS versions; a missing + // one is not an error, so log it and move on. TETHERKIT_DEBUG_TR(Msg::kNetSysctlUnreadableSkipped, entry.name, value.error().ToString()); continue; @@ -217,7 +222,8 @@ void FethInterface::Destroy() noexcept { TETHERKIT_ERROR_TR(Msg::kNetDestroyFailed, name, request.error().ToString()); return; } - // feth_clone_destroy 内部会自动先解绑 peer,无需我们先 Unpeer。 + // feth_clone_destroy unbinds the peer by itself, so there is no need for us + // to Unpeer first. if (const auto status = socket->Call(SIOCIFDESTROY, &*request, "ioctl(SIOCIFDESTROY)"); !status) { TETHERKIT_ERROR_TR(Msg::kNetDestroyFailed, name, status.error().ToString()); return; @@ -229,13 +235,15 @@ void FethInterface::Destroy() noexcept { Result FethInterface::Create(std::string_view requested_name) { TETHERKIT_ASSIGN_OR_RETURN(const auto socket, IoctlSocket::Open()); - // 名字为空 → 填驱动名 "feth" 作为通配,内核选最小空闲编号并写回完整名字。 + // An empty name -> fill in the driver name "feth" as the wildcard; the + // kernel picks the smallest free unit number and writes the full name back. const std::string_view name_to_request = requested_name.empty() ? std::string_view{kFethCloneName} : requested_name; TETHERKIT_ASSIGN_OR_RETURN(::ifreq request, MakeIfreq(name_to_request)); - // SIOCIFCREATE 与 SIOCIFCREATE2 对 feth 完全等价 —— feth_clone_create 忽略 - // params,而 SIOCIFCREATE2 唯一的区别就是多传一个 params 指针。用简单的那个。 + // SIOCIFCREATE and SIOCIFCREATE2 are exactly equivalent for feth -- + // feth_clone_create ignores params, and the only difference of SIOCIFCREATE2 + // is that it passes an extra params pointer. Use the simpler one. if (const auto status = socket.Call(SIOCIFCREATE, &request, "ioctl(SIOCIFCREATE)"); !status) { Error error = status.error(); if (error.Code() == EPERM) { @@ -250,11 +258,13 @@ Result FethInterface::Create(std::string_view requested_name) { std::move(error).WithContext(Tr(Msg::kNetFethCreateFailed, name_to_request))); } - // 通配创建时内核把完整名字(含编号)写回 ifr_name。 + // In wildcard creation the kernel writes the full name (number included) + // back into ifr_name. std::string created_name(request.ifr_name, ::strnlen(request.ifr_name, kInterfaceNameCapacity)); - // 先登记再返回:登记的意义就是「万一从这一刻起进程被强杀,下次也能清掉它」, - // 所以中间不能留任何窗口。 + // Register before returning: the whole point of registering is "if the + // process is killed from this moment on, the next start can still clean it + // up", so no window may be left in between. NotifyRegistry(created_name, true); TETHERKIT_INFO_TR(Msg::kNetCreated, created_name); return FethInterface{std::move(created_name)}; @@ -270,7 +280,8 @@ Status FethInterface::PeerWith(const FethInterface& peer) { TETHERKIT_ASSIGN_OR_RETURN(const auto socket, IoctlSocket::Open()); - // reserved 字段保持全零 —— 内核会校验,非零直接 EINVAL。 + // The reserved field stays all zeros -- the kernel validates it and returns + // EINVAL when it is nonzero. FethRequest payload{}; std::memcpy(payload.u.peer_name, peer.Name().data(), peer.Name().size()); @@ -301,7 +312,7 @@ Status FethInterface::Unpeer() { } TETHERKIT_ASSIGN_OR_RETURN(const auto socket, IoctlSocket::Open()); - // 空 peer 名(首字节 '\0')即表示解绑。 + // An empty peer name (first byte '\0') means unbind. FethRequest payload{}; TETHERKIT_RETURN_IF_ERROR(CallFethDriverIoctl( socket, name_, kSetDriverSpec, static_cast(FethSetCommand::kSetPeer), payload, @@ -360,8 +371,9 @@ Status FethInterface::SetMacAddress(const MacAddress& mac) { TETHERKIT_ASSIGN_OR_RETURN(const auto socket, IoctlSocket::Open()); TETHERKIT_ASSIGN_OR_RETURN(::ifreq request, MakeIfreq(name_)); - // feth_ioctl 直接把 ifr_addr 转给 ifnet_set_lladdr(ifp, sa_data, sa_len), - // 只看 sa_len 与 sa_data,不校验 sa_family。 + // feth_ioctl hands ifr_addr straight to ifnet_set_lladdr(ifp, sa_data, + // sa_len); it looks only at sa_len and sa_data and does not validate + // sa_family. request.ifr_addr.sa_len = static_cast(mac.size()); request.ifr_addr.sa_family = AF_LINK; std::memcpy(request.ifr_addr.sa_data, mac.data(), mac.size()); @@ -379,14 +391,15 @@ Status FethInterface::SetMacAddress(const MacAddress& mac) { } Result FethInterface::QueryMacAddress() const { - // 读 MAC 走 getifaddrs + AF_LINK 而非 ioctl:macOS 的 SDK 只提供 - // SIOCSIFLLADDR(写),没有对应的读 ioctl。getifaddrs 是 BSD 上读链路地址的 - // 标准做法,且不需要额外权限。 + // Reading the MAC goes through getifaddrs + AF_LINK rather than an ioctl: + // macOS's SDK only provides SIOCSIFLLADDR (write), with no matching read + // ioctl. getifaddrs is the standard way to read link-layer addresses on BSD + // and needs no extra privileges. ::ifaddrs* list = nullptr; if (::getifaddrs(&list) != 0) { return std::unexpected(Error::FromErrno(0, Tr(Msg::kNetGetifaddrsFailed))); } - // 用 RAII 保证任何返回路径都释放链表。 + // RAII guarantees the list is freed on every return path. const std::unique_ptr<::ifaddrs, decltype(&::freeifaddrs)> guard(list, &::freeifaddrs); for (const ::ifaddrs* entry = list; entry != nullptr; entry = entry->ifa_next) { @@ -415,11 +428,12 @@ Status FethInterface::SetUp(bool up) { TETHERKIT_ASSIGN_OR_RETURN(const auto socket, IoctlSocket::Open()); TETHERKIT_ASSIGN_OR_RETURN(::ifreq request, MakeIfreq(name_)); - // 先读回当前 flags 再改,避免把别的标志位清掉。 + // Read the current flags back before changing them, so no other flag bits + // get cleared. TETHERKIT_RETURN_IF_ERROR(socket.Call(SIOCGIFFLAGS, &request, "ioctl(SIOCGIFFLAGS)")); - // ifr_flags 是 short,而 IFF_* 在 64 位下可能超出 short 范围, - // 因此按 macOS 的惯例用 uint16 掩码运算。 + // ifr_flags is a short, while the IFF_* values can exceed the short range + // on 64-bit, so the mask arithmetic uses uint16 as is customary on macOS. auto flags = static_cast(request.ifr_flags); if (up) { flags |= static_cast(IFF_UP); @@ -455,7 +469,8 @@ Result FethPair::Create(std::uint32_t mtu, const MacAddress* system_ma return std::unexpected(Error::Generic(Tr(Msg::kNetFethPairNeedsRoot))); } - // 1. 校验创建期会被快照的 sysctl —— **必须在创建之前**。 + // 1. Verify the sysctls that get snapshotted at creation time -- **must + // happen before creating anything**. TETHERKIT_RETURN_IF_ERROR(VerifyFethSysctls()); TETHERKIT_ASSIGN_OR_RETURN(const std::uint32_t max_mtu, QueryFethMaxMtu()); @@ -463,20 +478,22 @@ Result FethPair::Create(std::uint32_t mtu, const MacAddress* system_ma return std::unexpected(Error::Generic(Tr(Msg::kNetMtuExceedsFethLimit, mtu, max_mtu))); } - // 2. 创建两张接口。 + // 2. Create the two interfaces. TETHERKIT_ASSIGN_OR_RETURN(FethInterface system_side, FethInterface::Create()); TETHERKIT_ASSIGN_OR_RETURN(FethInterface driver_side, FethInterface::Create()); - // 3. 配对 —— 在 UP 之前做,这样链路从一开始就是 up 的。 + // 3. Pair -- before UP, so the link is up from the very start. TETHERKIT_RETURN_IF_ERROR(driver_side.PeerWith(system_side)); - // 4. 两侧 MTU 必须一致,否则一侧能发的帧另一侧收不下。 + // 4. Both sides need the same MTU, otherwise a frame one side can send is + // a frame the other side cannot receive. TETHERKIT_RETURN_IF_ERROR(system_side.SetMtu(mtu)); TETHERKIT_RETURN_IF_ERROR(driver_side.SetMtu(mtu)); - // 5. 设系统侧 MAC —— **必须在 UP 之前**。 - // 驱动侧刻意保留内核分配的地址:两侧 MAC 必须不同,否则 IPv6 链路本地 - // 地址相同会触发 DAD 冲突。 + // 5. Set the system-side MAC -- **must happen before UP**. + // The driver side deliberately keeps the kernel-assigned address: the + // two MACs must differ, otherwise both sides end up with the same IPv6 + // link-local address and DAD reports an address conflict. if (system_mac != nullptr) { TETHERKIT_RETURN_IF_ERROR(system_side.SetMacAddress(*system_mac)); @@ -487,8 +504,9 @@ Result FethPair::Create(std::uint32_t mtu, const MacAddress* system_ma } } - // 6. 两侧都置 UP。 - // bpfwrite 里有硬检查:接口不是 IFF_UP 就返回 ENETDOWN。 + // 6. Bring both sides UP. + // bpfwrite has a hard check: it returns ENETDOWN when the interface is + // not IFF_UP. TETHERKIT_RETURN_IF_ERROR(driver_side.SetUp(true)); TETHERKIT_RETURN_IF_ERROR(system_side.SetUp(true)); diff --git a/src/net/loopback_link.cc b/src/net/loopback_link.cc index b421571..39392ff 100644 --- a/src/net/loopback_link.cc +++ b/src/net/loopback_link.cc @@ -50,7 +50,8 @@ Result LoopbackLink::ReadFrames() { inbound_.erase(inbound_.begin(), inbound_.begin() + static_cast(take)); } - // 视图必须在下一次 ReadFrames 之前有效,所以数据留在 read_storage_ 里。 + // The views must stay valid until the next ReadFrames, so the data lives on + // in read_storage_. for (const std::vector& frame : read_storage_) { read_views_.push_back( FrameView{.data = frame.data(), .length = static_cast(frame.size())}); @@ -74,7 +75,8 @@ Result LoopbackLink::WriteFrames(FrameBatch frames) { continue; } if (sent_.size() >= config_.sent_capacity) { - // 与真实 BPF 一致:写不下就停,让调用方从 frames_written 看出没写完。 + // Same contract as real BPF: stop when the write cannot fit, and let the + // caller infer the shortfall from frames_written. break; } sent_.emplace_back(frame.data, frame.data + frame.length); diff --git a/src/rndis/CMakeLists.txt b/src/rndis/CMakeLists.txt index 5bc686d..499831a 100644 --- a/src/rndis/CMakeLists.txt +++ b/src/rndis/CMakeLists.txt @@ -1,8 +1,9 @@ -# tk_rndis —— RNDIS 协议层 +# tk_rndis -- the RNDIS protocol layer # -# 纯逻辑模块:只做线格式编解码与状态机推演,**不碰任何 I/O**。 -# 这个约束是刻意的 —— 它让整个 RNDIS 实现可以在没有真实设备的机器上被完整 -# 单元测试覆盖(本项目的开发机既没有 USB 设备也没有 root 权限)。 +# Pure-logic module: only wire-format codec work and state-machine stepping, +# **touches no I/O at all**. This constraint is deliberate -- it lets the entire +# RNDIS implementation be fully covered by unit tests on machines without a real +# device (this project's dev machine has neither a USB device nor root). add_library( tk_rndis STATIC diff --git a/src/rndis/messages.cc b/src/rndis/messages.cc index 8bd6eec..802a5b7 100644 --- a/src/rndis/messages.cc +++ b/src/rndis/messages.cc @@ -12,10 +12,11 @@ namespace tetherkit::rndis { namespace { -/// 构造一个带 RNDIS 状态码名字的错误。 +/// Build an error carrying the RNDIS status code's name. /// -/// 状态码到名字的映射只存在于 protocol.cc 一处(tk_common 不认识 RNDIS), -/// 因此这里把名字直接拼进 context 串,Error 自身只保留原始数值。 +/// The status-code-to-name map lives only in protocol.cc (tk_common knows +/// nothing about RNDIS), so the name is concatenated straight into the context +/// string here and the Error itself keeps only the raw value. Error MakeStatusError(std::uint32_t status, std::string_view what) { const std::string_view name = StatusName(status); if (name.empty()) { @@ -24,7 +25,7 @@ Error MakeStatusError(std::uint32_t status, std::string_view what) { return Error::FromRndisStatus(status, Tr(Msg::kRndisStatusSuffix, what, name)); } -/// 校验缓冲区至少有 `needed` 字节。 +/// Check that the buffer has at least `needed` bytes. Status RequireBytes(std::span buffer, std::size_t needed, std::string_view what) { if (buffer.size() < needed) { return std::unexpected( @@ -33,7 +34,7 @@ Status RequireBytes(std::span buffer, std::size_t needed, std:: return Ok(); } -/// 校验待写入缓冲区容量。 +/// Check the capacity of the buffer about to be written. Status RequireCapacity(std::span buffer, std::size_t needed, std::string_view what) { if (buffer.size() < needed) { return std::unexpected( @@ -42,7 +43,8 @@ Status RequireCapacity(std::span buffer, std::size_t needed, std::str return Ok(); } -/// 校验一条完成消息:类型正确、长度自洽、状态成功。 +/// Validate a completion message: right type, self-consistent length, +/// successful status. Status ValidateCompletion(std::span buffer, MessageType expected_type, std::uint32_t minimum_bytes, std::string_view what) { TETHERKIT_RETURN_IF_ERROR(RequireBytes(buffer, minimum_bytes, what)); @@ -63,15 +65,17 @@ Status ValidateCompletion(std::span buffer, MessageType expecte return Ok(); } -/// 写入公共头部(Type + Length)。 +/// Write the common header (Type + Length). void WriteHeader(std::span buffer, MessageType type, std::uint32_t message_length) { StoreLe32(buffer.data() + kMessageTypeOffset, ToRaw(type)); StoreLe32(buffer.data() + kMessageLengthOffset, message_length); } -/// 解析一个「偏移 + 长度」描述的内嵌缓冲区,基准点为消息起始 + 8。 +/// Resolve an inline buffer described by "offset + length", with its base +/// point at message start + 8. /// -/// 返回指向 `buffer` 内部的视图。偏移或长度越界时返回错误。 +/// Returns a view into `buffer`. An out-of-range offset or length yields an +/// error. Result> ResolveInlineBuffer(std::span buffer, std::uint32_t message_length, std::uint32_t relative_offset, @@ -80,7 +84,8 @@ Result> ResolveInlineBuffer(std::span{}; } - // 绝对偏移 = 8 + 字段值,见 protocol.h 文件头规则 2。 + // Absolute offset = 8 + field value; see rule 2 in the protocol.h file + // header. const std::uint64_t absolute_offset = static_cast(kOffsetFieldBase) + relative_offset; const std::uint64_t end = absolute_offset + length; @@ -102,7 +107,7 @@ std::array FormatMac(const MacAddress& mac) noexcept { } // ============================================================================= -// 通用头部 +// Common header // ============================================================================= Result DecodeMessageHeader(std::span buffer) { @@ -159,12 +164,12 @@ Result DecodeInitializeComplete(std::span b Result Negotiate(const InitializeComplete& complete, std::uint32_t requested_mtu, std::uint32_t host_transfer_size_limit) { - // ---- 介质必须是以太网 ---- + // ---- The medium must be Ethernet ---- if (complete.medium != ToRaw(Medium::kEthernet)) { return std::unexpected(Error::Generic(Tr(Msg::kRndisUnsupportedMedium, complete.medium))); } - // ---- 面向连接设备不支持 ---- + // ---- Connection-oriented devices are not supported ---- const bool connectionless = (complete.device_flags & static_cast(DeviceFlags::kConnectionless)) != 0; const bool connection_oriented = @@ -173,11 +178,12 @@ Result Negotiate(const InitializeComplete& complete, return std::unexpected(Error::Generic(Tr(Msg::kRndisConnectionOriented))); } if (!connectionless) { - // 有设备两个位都不置。宽容处理:按无连接继续,只告警。 + // Some devices set neither bit. Be lenient: continue as connectionless + // and only warn. TETHERKIT_WARN_TR(Msg::kRndisNoConnectionlessFlag, complete.device_flags); } - // ---- 版本 ---- + // ---- Version ---- if (complete.major_version != kMajorVersion) { return std::unexpected(Error::Generic( Tr(Msg::kRndisUnsupportedMajorVersion, complete.major_version, kMajorVersion))); @@ -186,12 +192,14 @@ Result Negotiate(const InitializeComplete& complete, NegotiatedParameters params; params.connectionless = true; - // ---- MaxPacketsPerMessage:0 当 1 ---- - // 主线 Linux gadget 报 1;打了高通上行聚合补丁的 Android 内核报 3 或更多。 + // ---- MaxPacketsPerMessage: treat 0 as 1 ---- + // Mainline Linux gadgets report 1; Android kernels with the Qualcomm + // upstream-aggregation patch report 3 or more. params.max_packets_per_message = std::max(1, complete.max_packets_per_message); - // ---- PacketAlignmentFactor:钳到 7 ---- - // 超过 7 属于协议违规。不钳位的话 1u << factor 会溢出或算出荒谬的填充长度。 + // ---- PacketAlignmentFactor: clamp to 7 ---- + // Anything above 7 is a protocol violation. Without the clamp, 1u << factor + // overflows or produces an absurd padding length. std::uint32_t alignment_factor = complete.packet_alignment_factor; if (alignment_factor > kMaxPacketAlignmentFactor) { TETHERKIT_WARN_TR(Msg::kRndisAlignmentFactorClamped, alignment_factor, @@ -200,15 +208,16 @@ Result Negotiate(const InitializeComplete& complete, } params.tx_alignment_bytes = 1U << alignment_factor; - // ---- MaxTransferSize:双向钳位 ---- + // ---- MaxTransferSize: clamp in both directions ---- std::uint32_t device_limit = complete.max_transfer_size; if (device_limit <= kHardHeaderBytes) { return std::unexpected( Error::Generic(Tr(Msg::kRndisMaxTransferTooSmall, device_limit, kHardHeaderBytes))); } - // 上钳:某些 WinCE / Windows Mobile 设备宣称 8KB 或 16KB 的巨帧上限, - // 对这种链路速率毫无意义,只会让我们分配巨大的传输缓冲。不盲从设备。 + // Upper clamp: some WinCE / Windows Mobile devices advertise giant 8KB or + // 16KB frame caps that are meaningless at this link rate and would only make + // us allocate huge transfer buffers. Do not follow the device blindly. if (device_limit > host_transfer_size_limit) { TETHERKIT_INFO_TR(Msg::kRndisMaxTransferClampedToHost, device_limit, host_transfer_size_limit); @@ -216,8 +225,10 @@ Result Negotiate(const InitializeComplete& complete, } params.device_max_transfer_size = device_limit; - // 下钳:若设备装不下一个按 requested_mtu 计算的满帧,就反推出可行的 MTU。 - // 实测案例:HTC Diamond 报 1536,比 hard_mtu(1558) 小,MTU 需降到 1478。 + // Lower clamp: if the device cannot hold a full frame computed from + // requested_mtu, back out a workable MTU instead. + // Measured case: an HTC Diamond reports 1536, less than hard_mtu(1558), so + // the MTU has to drop to 1478. params.mtu = requested_mtu; const std::uint32_t hard_mtu = HardMtuFor(requested_mtu); if (device_limit < hard_mtu) { @@ -245,9 +256,11 @@ Result EncodeHalt(std::uint32_t request_id, std::span // ============================================================================= Result Encode(const QueryRequest& request, std::span buffer) { - // 未文档化但必须遵守的规则(见 QueryRequest::expected_response_bytes 注释): - // 变长 OID → InformationBufferLength 必须为 0; - // 定长 OID → 必须 ≥ 期望响应长度,且消息尾部要真的有那么多字节。 + // Undocumented but mandatory rules (see the comment on + // QueryRequest::expected_response_bytes): + // variable-length OID -> InformationBufferLength must be 0; + // fixed-length OID -> it must be >= the expected response length, and the + // message tail must really contain that many bytes. const bool variable_length = IsVariableLengthOid(request.oid); const std::uint32_t info_length = variable_length ? 0 : request.expected_response_bytes; @@ -258,12 +271,14 @@ Result Encode(const QueryRequest& request, std::span b StoreLe32(buffer.data() + kQuerySetRequestIdOffset, request.request_id); StoreLe32(buffer.data() + kQuerySetOidOffset, ToRaw(request.oid)); StoreLe32(buffer.data() + kQuerySetInfoBufferLengthOffset, info_length); - // 长度为 0 时偏移也必须为 0,否则某些设备会拒绝。 + // When the length is 0 the offset must also be 0, or some devices reject + // it. StoreLe32(buffer.data() + kQuerySetInfoBufferOffsetOffset, info_length == 0 ? 0 : kQuerySetInlineInfoOffset); StoreLe32(buffer.data() + kQuerySetDeviceVcHandleOffset, 0); - // 尾部填零,作为设备写回结果的占位区。 + // Zero-fill the tail as the placeholder area where the device writes the + // result back. if (info_length != 0) { std::memset(buffer.data() + kQuerySetHeaderBytes, 0, info_length); } @@ -288,8 +303,10 @@ Result DecodeQueryComplete(std::span buffer) { }; if (status != ToRaw(StatusCode::kSuccess)) { - // 失败时不解析信息缓冲区 —— 设备可能没填。把状态原样返回给调用方判断, - // 因为「某个可选 OID 不支持」是正常情况,不该一律当致命错误。 + // Do not parse the information buffer on failure -- the device may not + // have filled it. Return the status untouched for the caller to judge, + // because "some optional OID is unsupported" is normal and must not be + // treated as fatal across the board. return complete; } @@ -343,7 +360,7 @@ Result DecodeSetComplete(std::span buffer) { Result EncodeReset(std::span buffer) { TETHERKIT_RETURN_IF_ERROR(RequireCapacity(buffer, kResetMsgBytes, "REMOTE_NDIS_RESET_MSG")); WriteHeader(buffer, MessageType::kReset, kResetMsgBytes); - // offset 8 是 Reserved,不是 RequestId —— 必须写 0。 + // Offset 8 is Reserved, not RequestId -- it must be written as 0. StoreLe32(buffer.data() + kResetReservedOffset, 0); return kResetMsgBytes; } @@ -351,7 +368,8 @@ Result EncodeReset(std::span buffer) { Result DecodeResetComplete(std::span buffer) { TETHERKIT_RETURN_IF_ERROR(ValidateCompletion(buffer, MessageType::kResetComplete, kResetCmpltBytes, "REMOTE_NDIS_RESET_CMPLT")); - // 注意偏移:RESET_CMPLT 没有 RequestId,Status 在 offset 8 而非 12。 + // Note the offsets: RESET_CMPLT has no RequestId, so Status sits at + // offset 8 rather than 12. return ResetComplete{ .status = LoadLe32(buffer.data() + kResetCmpltStatusOffset), .addressing_reset = LoadLe32(buffer.data() + kResetCmpltAddressingResetOffset) != 0, @@ -415,16 +433,18 @@ Result DecodeIndicateStatus(std::span buffer) { const std::uint32_t buffer_length = LoadLe32(base + kIndicateStatusBufferLengthOffset); if (buffer_length == 0) { - // 绝大多数情况:MEDIA_CONNECT / MEDIA_DISCONNECT 不带负载。 + // The overwhelmingly common case: MEDIA_CONNECT / MEDIA_DISCONNECT carry + // no payload. return indication; } - // StatusBufferOffset 的基准点在规范里是矛盾的,两种解释都试一遍。 - // 详见 messages.h 中 DecodeIndicateStatus 的文档注释。 + // The base point of StatusBufferOffset is contradictory in the spec; try + // both interpretations. See the documentation comment on + // DecodeIndicateStatus in messages.h. const std::uint32_t relative_offset = LoadLe32(base + kIndicateStatusBufferOffsetOffset); const std::uint64_t candidates[] = { - static_cast(kOffsetFieldBase) + relative_offset, // 与 QUERY/SET 一致 - relative_offset, // 按 MS 文档字面 + static_cast(kOffsetFieldBase) + relative_offset, // same as QUERY/SET + relative_offset, // literal per MS docs }; for (const std::uint64_t offset : candidates) { if (offset + buffer_length <= message_length) { @@ -435,16 +455,18 @@ Result DecodeIndicateStatus(std::span buffer) { } if (indication.status_buffer.empty()) { - // 两种解释都越界:丢掉可选负载,但**仍然成功返回** —— status 本身有用 - // (比如它可能就是 MEDIA_DISCONNECT),不能因为解析不了一个可选字段 - // 就把链路判死。 + // Both interpretations are out of range: drop the optional payload but + // **still return success** -- the status itself is useful (it may be + // MEDIA_DISCONNECT), and an unparseable optional field must not condemn + // the link. TETHERKIT_WARN_TR(Msg::kRndisIndicateStatusBufferOutOfBounds, relative_offset, buffer_length, message_length); return indication; } - // 恰好 8 字节时按 RNDIS_Diagnostic_Info 解析 —— 设备用它报告我们发过去的 - // 消息哪里不合法(DiagStatus + ErrorOffset),对排障极有价值。 + // At exactly 8 bytes, parse it as RNDIS_Diagnostic_Info -- the device uses + // it to report which part of the message we sent was invalid + // (DiagStatus + ErrorOffset), which is invaluable for troubleshooting. if (indication.status_buffer.size() == kDiagnosticInfoBytes) { indication.has_diagnostic_info = true; indication.diagnostic_status = @@ -456,7 +478,7 @@ Result DecodeIndicateStatus(std::span buffer) { } // ============================================================================= -// OID 负载解析 +// OID payload parsing // ============================================================================= Result ParseUint32(std::span information) { @@ -465,7 +487,7 @@ Result ParseUint32(std::span information) { } Result ParseCounter(std::span information) { - // 统计类 OID 可能返回 4 或 8 字节,两者都必须接受。 + // Statistics OIDs may return either 4 or 8 bytes; both must be accepted. if (information.size() >= 8) { return LoadLe64(information.data()); } diff --git a/src/rndis/protocol.cc b/src/rndis/protocol.cc index b78fe3f..523ef40 100644 --- a/src/rndis/protocol.cc +++ b/src/rndis/protocol.cc @@ -34,7 +34,8 @@ std::string_view MessageTypeName(std::uint32_t message_type) noexcept { return "RNDIS_MSG_BUS"; } - // CONDIS(面向连接)消息族:本项目不支持,但要能明确报出来。 + // CONDIS (connection-oriented) message family: not supported by this + // project, but the messages must still be reported unambiguously. switch (static_cast(message_type & ~kMessageCompletionFlag)) { case CondisMessageType::kCreateVc: return "RNDIS_MSG_MP_CREATE_VC"; diff --git a/src/rndis/state_machine.cc b/src/rndis/state_machine.cc index 41473d3..ea33a6d 100644 --- a/src/rndis/state_machine.cc +++ b/src/rndis/state_machine.cc @@ -12,23 +12,25 @@ namespace tetherkit::rndis { namespace { -/// QUERY 定长 OID 时给的占位缓冲大小。 +/// Placeholder buffer size used when QUERYing a fixed-length OID. /// -/// 对返回定长结果的 OID,InformationBufferLength 必须 >= 期望响应长度,否则微软 -/// ActiveSync 实现会回 RNDIS_STATUS_INVALID_LENGTH。Linux 查 MAC 时给的是 48, -/// 这里沿用同一个「宽松但足够」的值作为多数定长 OID 的默认占位。 +/// For OIDs that return a fixed-length result, InformationBufferLength must be +/// >= the expected response length or Microsoft's ActiveSync implementation +/// replies with RNDIS_STATUS_INVALID_LENGTH. Linux passes 48 when querying the +/// MAC; we reuse the same "loose but sufficient" value as the default +/// placeholder for most fixed-length OIDs. constexpr std::uint32_t kFixedOidPlaceholderBytes = 48; -/// 从可能不带 NUL 结尾的 ASCII 缓冲里取出字符串。 +/// Extract a string from an ASCII buffer that may lack a NUL terminator. [[nodiscard]] std::string ToPrintableString(std::span bytes) { std::string text; text.reserve(bytes.size()); for (const std::byte byte : bytes) { const auto character = static_cast(byte); if (character == '\0') { - break; // 有些设备确实带 NUL,遇到就停 + break; // some devices do NUL-terminate; stop when we see it } - // 过滤掉不可打印字符,避免把控制字符打进日志。 + // Filter out non-printable characters so control chars never reach the log. text.push_back(character >= 0x20 && character < 0x7F ? character : '?'); } return text; @@ -63,8 +65,9 @@ StateMachine::StateMachine(ControlChannel& channel, StateMachineObserver& observ } std::uint32_t StateMachine::NextRequestId() noexcept { - // 跳过 0:某些设备把 RequestId == 0 当作无效值(FreeBSD 的实现干脆全填 0, - // 说明设备普遍不校验,但保守起见还是避开)。 + // Skip 0: some devices treat RequestId == 0 as invalid (FreeBSD's + // implementation fills everything with 0, showing devices generally do not + // validate it, but we avoid it conservatively anyway). const std::uint32_t id = next_request_id_++; if (next_request_id_ == 0) { next_request_id_ = 1; @@ -83,7 +86,7 @@ void StateMachine::TransitionTo(State next) { } // ============================================================================= -// 控制通道往返 +// Control channel round trips // ============================================================================= Result StateMachine::PumpOnce(std::optional expected_reply, @@ -92,7 +95,8 @@ Result StateMachine::PumpOnce(std::optional expected_reply, channel_->ReceiveMessage()); if (message.empty()) { - // 规范:设备尚无有效响应时返回 1 字节 0x00 而非 STALL。不是错误。 + // Spec: the device returns a single 0x00 byte instead of STALL while it + // has no valid response yet. Not an error. return false; } MarkDeviceActivity(); @@ -103,31 +107,34 @@ Result StateMachine::PumpOnce(std::optional expected_reply, name.empty() ? Text(Msg::kRndisUnknownMessage) : name, header.message_type, header.message_length); - // ---- 设备主动推送的消息:分派掉,继续等我们要的那条 ---- + // ---- Unsolicited device pushes: dispatch them and keep waiting for ours ---- if (header.message_type == ToRaw(MessageType::kIndicateStatus)) { HandleIndicateStatus(message); return false; } if (header.message_type == ToRaw(MessageType::kKeepAlive)) { - // 设备发起的保活。**必须回复**,否则设备可能判定主机已死而断开。 + // Device-initiated keep-alive. We **must reply**, otherwise the device may + // decide the host is dead and disconnect. TETHERKIT_RETURN_IF_ERROR(HandleDeviceKeepAlive(message)); return false; } - // ---- 面向连接设备的消息:明确报不支持,而不是含糊的「未知消息」---- + // ---- Messages from connection-oriented devices: report them as unsupported + // explicitly rather than with a vague "unknown message" ---- if (IsCondis(header.message_type)) { return std::unexpected(Error::Generic(Tr(Msg::kRndisCondisMessageReceived, name.empty() ? Text(Msg::kRndisUnknown) : name))); } - // ---- 是我们等的那条吗 ---- + // ---- Is this the one we are waiting for? ---- if (expected_reply.has_value() && header.message_type == ToRaw(*expected_reply)) { out_response = message; return true; } - // 不是我们等的,也不是已知的推送消息。可能是上一次超时请求的迟到响应 —— - // 丢弃并继续(不能当致命错误,否则一次超时就会把链路判死)。 + // Not the one we awaited, and not a known push. It may be a late response to + // a previously timed-out request -- drop it and continue (treating it as + // fatal would let a single timeout kill the link). TETHERKIT_WARN_TR(Msg::kRndisDiscardUnexpectedMessage, name.empty() ? Text(Msg::kRndisUnknown) : name, header.message_type); return false; @@ -137,15 +144,20 @@ Result StateMachine::Transact(std::span MessageType expected_reply) { TETHERKIT_RETURN_IF_ERROR(channel_->SendMessage(request)); - // 先等中断端点的 RESPONSE_AVAILABLE 通知,再去取响应。 + // Wait for the interrupt endpoint's RESPONSE_AVAILABLE notification first, + // then fetch the response. // - // 两类设备行为都要兼容: - // * 规范做法是等通知; - // * 但也存在「必须先被中断端点读过一次才会在控制端点上作答」的设备; - // * 而 Linux 干脆完全忽略中断端点,直接轮询控制端点。 - // 所以:有中断端点就先等一次通知(超时也不算错),然后无论如何都去轮询。 - // 同样不能为 0(timeout=0 在 darwin 上是无限等待)。注意 response_poll_interval - // 可以被配成 0(测试里就是),所以这里必须用 max 兜底。 + // Both device behaviors must be accommodated: + // * the spec-conforming one waits for the notification; + // * but some devices only answer on the control endpoint after the + // interrupt endpoint has been read at least once; + // * and Linux simply ignores the interrupt endpoint and polls the control + // endpoint directly. + // So: if there is an interrupt endpoint, wait for one notification (a + // timeout is not an error either), then poll regardless. + // Never 0 either (timeout=0 means infinite wait on darwin). Note + // response_poll_interval may be configured as 0 (it is in tests), so max() + // must backstop it here. const std::uint32_t notification_timeout = std::max(kProbeOnlyTimeoutMillis, std::min(config_.control_timeout_millis, config_.response_poll_interval_millis * 4)); @@ -161,8 +173,9 @@ Result StateMachine::Transact(std::span if (matched) { return Exchange{.response = response}; } - // 还没准备好。睡一小会儿再试 —— 这里必须睡,否则会把控制端点打成忙轮询, - // 而 Apple Silicon 上每次控制传输本身就是毫秒级开销。 + // Not ready yet. Sleep briefly and retry -- we must sleep here, otherwise + // the control endpoint turns into a busy-poll, and each control transfer + // costs milliseconds on Apple Silicon. std::this_thread::sleep_for( std::chrono::milliseconds(config_.response_poll_interval_millis)); } @@ -174,21 +187,22 @@ Result StateMachine::Transact(std::span } // ============================================================================= -// 设备推送消息的处理 +// Handling unsolicited device pushes // ============================================================================= void StateMachine::HandleIndicateStatus(std::span message) { const auto indication = DecodeIndicateStatus(message); if (!indication) { - // 解析失败不该让链路死掉 —— 这是个纯通报消息。 + // A parse failure must not kill the link -- this is a pure notification. TETHERKIT_WARN_TR(Msg::kRndisIndicateStatusParseFailed, indication.error().ToString()); return; } const std::string_view status_name = StatusName(indication->status); if (indication->has_diagnostic_info) { - // 设备用 Rndis_Diagnostic_Info 告诉我们「你发过来的消息第 N 字节不合法」, - // 对排查我们自己的编码 bug 极有价值,所以单独打出来。 + // The device uses Rndis_Diagnostic_Info to tell us "byte N of the message + // you sent is invalid", which is invaluable for tracking down our own + // encoding bugs, so we log it separately. TETHERKIT_WARN_TR(Msg::kRndisDeviceIndicatedWithDiagnostics, status_name.empty() ? Text(Msg::kRndisUnknownStatus) : status_name, indication->status, indication->diagnostic_status, @@ -217,14 +231,16 @@ void StateMachine::HandleIndicateStatus(std::span message) { break; default: - // 其它状态只记日志。特别是 kInvalidData —— 它说明我们发的消息有问题, - // 但不该据此断开链路(诊断信息已经打出来了)。 + // Other statuses are only logged. In particular kInvalidData -- it means + // the message we sent was malformed, but we should not drop the link over + // it (the diagnostics have already been printed). break; } } Status StateMachine::HandleDeviceKeepAlive(std::span message) { - // 设备发起的 KEEPALIVE_MSG 的 RequestId 在 offset 8,与主机发起时同布局。 + // A device-initiated KEEPALIVE_MSG carries its RequestId at offset 8, with + // the same layout as when the host initiates it. if (message.size() < kKeepAliveMsgBytes) { return std::unexpected(Error::Generic(Tr(Msg::kRndisKeepAliveMsgTooShort))); } @@ -238,7 +254,7 @@ Status StateMachine::HandleDeviceKeepAlive(std::span message) { } // ============================================================================= -// OID 读写 +// OID reads and writes // ============================================================================= Result> StateMachine::QueryOid(Oid oid, std::uint32_t expected_bytes, @@ -268,7 +284,8 @@ Result> StateMachine::QueryOid(Oid oid, std::uint32_t if (complete.status != ToRaw(StatusCode::kSuccess)) { const std::string_view status_name = StatusName(complete.status); if (!fatal) { - // 可选 OID 返回 NOT_SUPPORTED 是完全正常的(例如 OID_GEN_PHYSICAL_MEDIUM)。 + // An optional OID returning NOT_SUPPORTED is perfectly normal (e.g. + // OID_GEN_PHYSICAL_MEDIUM). TETHERKIT_DEBUG_TR(Msg::kRndisOptionalOidUnsupported, name, status_name.empty() ? Text(Msg::kRndisUnknownStatus) : status_name); return std::span{}; @@ -305,12 +322,13 @@ Status StateMachine::SetOidUint32(Oid oid, std::uint32_t value) { } // ============================================================================= -// 启动序列 +// Start-up sequence // ============================================================================= Status StateMachine::CollectDeviceInfo() { - // ---- 物理介质:**可选 OID**,失败不致命 ---- - // Linux 查它时 in_len 传 4,失败就当 UNSPECIFIED 继续。 + // ---- Physical medium: **optional OID**, a failure is not fatal ---- + // Linux passes in_len 4 when querying it and treats failure as UNSPECIFIED, + // then continues. if (const auto medium = QueryOid(Oid::kGenPhysicalMedium, 4, /*fatal=*/false); medium && !medium->empty()) { if (const auto value = ParseUint32(*medium)) { @@ -318,8 +336,9 @@ Status StateMachine::CollectDeviceInfo() { } } - // ---- 永久 MAC:**致命** ---- - // 这是主机侧 feth 要采用的地址,拿不到就没法正确搭建网卡。 + // ---- Permanent MAC: **fatal** ---- + // This is the address the host-side feth will adopt; without it we cannot + // build the interface correctly. { TETHERKIT_ASSIGN_OR_RETURN( const std::span payload, @@ -329,7 +348,7 @@ Status StateMachine::CollectDeviceInfo() { TETHERKIT_INFO_TR(Msg::kRndisPermanentMac, FormatMac(info_.permanent_address).data()); } - // ---- 当前 MAC:不致命(多数设备与永久 MAC 相同)---- + // ---- Current MAC: not fatal (most devices report the same as permanent) ---- if (const auto payload = QueryOid(Oid::kEthernetCurrentAddress, kFixedOidPlaceholderBytes, /*fatal=*/false); payload && !payload->empty()) { @@ -343,13 +362,14 @@ Status StateMachine::CollectDeviceInfo() { info_.has_current_address = true; } - // ---- 最大帧长:不致命(协商结果已经给了 MTU)---- + // ---- Maximum frame size: not fatal (negotiation already gave us an MTU) ---- if (const auto payload = QueryOid(Oid::kGenMaximumFrameSize, 4, /*fatal=*/false); payload && !payload->empty()) { if (const auto value = ParseUint32(*payload)) { info_.maximum_frame_size = *value; - // 设备说它最大只收 N 字节净荷,而我们协商出的 MTU 更大 —— 听设备的。 + // The device says it accepts at most N bytes of payload while we + // negotiated a larger MTU -- the device wins. if (*value != 0 && *value < parameters_.mtu) { TETHERKIT_WARN_TR(Msg::kRndisMtuLoweredToDeviceFrameSize, *value, parameters_.mtu); parameters_.mtu = *value; @@ -357,7 +377,7 @@ Status StateMachine::CollectDeviceInfo() { } } - // ---- 链路速率:不致命,仅用于日志 ---- + // ---- Link speed: not fatal, logging only ---- if (const auto payload = QueryOid(Oid::kGenLinkSpeed, 4, /*fatal=*/false); payload && !payload->empty()) { if (const auto value = ParseUint32(*payload)) { @@ -365,7 +385,7 @@ Status StateMachine::CollectDeviceInfo() { } } - // ---- 媒体连接状态:不致命。**注意 0 表示已连接** ---- + // ---- Media connection status: not fatal. **Note 0 means connected** ---- if (const auto payload = QueryOid(Oid::kGenMediaConnectStatus, 4, /*fatal=*/false); payload && !payload->empty()) { if (const auto value = ParseUint32(*payload)) { @@ -373,18 +393,20 @@ Status StateMachine::CollectDeviceInfo() { link_connected_ = info_.media_state == MediaState::kConnected; } } else { - // 查不到就假定已连接 —— 设备既然在跑 RNDIS,链路大概率是通的。 + // If we cannot query it, assume connected -- a device running RNDIS has a + // good chance of having a working link. link_connected_ = true; } - // ---- 厂商信息:纯诊断用 ---- + // ---- Vendor information: diagnostics only ---- if (const auto payload = QueryOid(Oid::kGenVendorId, 4, /*fatal=*/false); payload && !payload->empty()) { if (const auto value = ParseUint32(*payload)) { info_.vendor_id = *value; } } - // 厂商描述是**变长** OID,expected_bytes 必须传 0。 + // The vendor description is a **variable-length** OID, so expected_bytes + // must be 0. if (const auto payload = QueryOid(Oid::kGenVendorDescription, 0, /*fatal=*/false); payload && !payload->empty()) { info_.vendor_description = ToPrintableString(*payload); @@ -405,7 +427,7 @@ Status StateMachine::Start() { TransitionTo(State::kInitializing); - // ---- 第 1 步:INITIALIZE ---- + // ---- Step 1: INITIALIZE ---- { TETHERKIT_ASSIGN_OR_RETURN( const std::uint32_t written, @@ -435,7 +457,8 @@ Status StateMachine::Start() { } parameters_ = *negotiated; - // 设备汇报的聚合包数只是它的**宣称**,调用方可以不信任它(见配置项注释)。 + // The packet count the device reports is only its **claim**; callers may + // distrust it (see the config field's comment). if (config_.max_tx_packets_per_message != 0 && parameters_.max_packets_per_message > config_.max_tx_packets_per_message) { TETHERKIT_INFO_TR(Msg::kRndisMaxPacketsClamped, parameters_.max_packets_per_message, @@ -450,14 +473,15 @@ Status StateMachine::Start() { TransitionTo(State::kInitialized); - // ---- 第 2~4 步:采集设备信息 ---- + // ---- Steps 2-4: collect device information ---- if (const auto status = CollectDeviceInfo(); !status) { SendHalt(); TransitionTo(State::kUninitialized); return status; } - // ---- 第 5 步:设置包过滤 → 设备进入 data-initialized,数据开始流动 ---- + // ---- Step 5: set the packet filter -> the device enters data-initialized + // and data starts flowing ---- if (const auto status = SetOidUint32(Oid::kGenCurrentPacketFilter, config_.packet_filter); !status) { SendHalt(); @@ -478,7 +502,7 @@ Status StateMachine::Start() { } // ============================================================================= -// 周期性工作 +// Periodic work // ============================================================================= std::uint32_t StateMachine::MillisUntilNextPoll() const noexcept { @@ -491,32 +515,37 @@ std::uint32_t StateMachine::MillisUntilNextPoll() const noexcept { Status StateMachine::Poll() { if (state_ != State::kDataInitialized && state_ != State::kInitialized) { - return Ok(); // 未就绪或正在停机,无事可做 + return Ok(); // not ready or shutting down; nothing to do } - // ---- 先排空设备主动推送的消息 ---- + // ---- Drain unsolicited device pushes first ---- // - // 不排空的话它们会堆在设备的响应队列里,把我们后续请求的响应挤到后面, - // 导致每次 Transact 都要多轮几次才拿到想要的那条。 + // If we do not, they pile up in the device's response queue and push the + // responses to our own requests behind them, forcing every Transact to spin + // through several extra rounds to get the reply it wants. // - // 只在有中断通知时才去读控制端点 —— 否则每次 Poll 都要发一次 - // GET_ENCAPSULATED_RESPONSE,而 Apple Silicon 上这是毫秒级开销。 + // Only read the control endpoint when there is an interrupt notification -- + // otherwise every Poll would issue a GET_ENCAPSULATED_RESPONSE, which costs + // milliseconds on Apple Silicon. // - // ⚠️ 这里**绝不能传 0**:libusb 在 darwin 上 timeout=0 表示无限等待, - // 会把控制线程永久卡死。用 kProbeOnlyTimeoutMillis(1 ms)—— - // Poll 本身每几百毫秒才跑一次,1 ms 的阻塞可以忽略。 + // Warning: **never pass 0 here**: on darwin libusb treats timeout=0 as an + // infinite wait and would wedge the control thread forever. Use + // kProbeOnlyTimeoutMillis (1 ms) -- Poll itself only runs every few hundred + // milliseconds, so a 1 ms block is negligible. if (channel_->WaitForNotification(kProbeOnlyTimeoutMillis) == NotificationResult::kResponseAvailable) { std::span ignored; - // 最多排 8 条,避免设备疯狂推送时卡在这里。 + // Drain at most 8 messages so a device that pushes frantically cannot + // pin us here. for (int i = 0; i < 8; ++i) { const auto pumped = PumpOnce(std::nullopt, ignored); if (!pumped) { return std::unexpected( Error{pumped.error()}.WithContext(Tr(Msg::kRndisDrainControlFailed))); } - // PumpOnce 在没有更多消息时返回 false 且不报错,无法区分「空了」和 - // 「处理了一条推送」。用通知状态再判一次。 + // PumpOnce returns false without an error when there are no more + // messages, so it cannot distinguish "queue empty" from "handled a + // push". Check the notification state once more. if (channel_->WaitForNotification(kProbeOnlyTimeoutMillis) != NotificationResult::kResponseAvailable) { break; @@ -524,10 +553,12 @@ Status StateMachine::Poll() { } } - // ---- 保活 ---- + // ---- Keep-alive ---- // - // 语义是「距上次从设备收到任何消息已超过保活周期」才发,而不是无条件定时发。 - // 通道活跃时无谓的保活往返纯属浪费(尤其在 Apple Silicon 上)。 + // The semantics are "more than a keep-alive period has passed since we last + // received anything from the device", not an unconditional timer. When the + // channel is busy a pointless keep-alive round trip is pure waste (on Apple + // Silicon especially). const Nanos now = MonotonicNanos(); if (now < next_keepalive_deadline_) { return Ok(); @@ -539,7 +570,7 @@ Status StateMachine::Poll() { next_keepalive_deadline_ = now + interval_nanos; if (idle_nanos < interval_nanos) { - // 通道一直有数据在走,不需要保活。 + // The channel has been carrying data all along; no keep-alive needed. return Ok(); } @@ -557,7 +588,7 @@ Status StateMachine::Poll() { observer_->OnFatalError(fatal); return std::unexpected(std::move(fatal)); } - return Ok(); // 还没到阈值,下个周期再试 + return Ok(); // below the threshold; try again next period } const auto complete = DecodeKeepAliveComplete(exchange->response); @@ -567,7 +598,8 @@ Status StateMachine::Poll() { } if (complete->status != ToRaw(StatusCode::kSuccess)) { - // 设备明确回了失败状态。这通常意味着设备想让我们复位。 + // The device explicitly returned a failure status. That usually means it + // wants us to reset. const std::string_view name = StatusName(complete->status); TETHERKIT_WARN_TR(Msg::kRndisKeepAliveRejected, name.empty() ? Text(Msg::kRndisUnknownStatus) : name); @@ -580,14 +612,14 @@ Status StateMachine::Poll() { } // ============================================================================= -// 复位 +// Reset // ============================================================================= Status StateMachine::Reset() { TETHERKIT_INFO_TR(Msg::kRndisResetStarted); - // RESET_MSG **没有 RequestId**(offset 8 是 Reserved),因此无法用 ID 配对, - // 同一时刻只能有一个 RESET 在飞。 + // RESET_MSG has **no RequestId** (offset 8 is Reserved), so it cannot be + // paired by ID; only one RESET may be in flight at a time. TETHERKIT_ASSIGN_OR_RETURN(const std::uint32_t written, EncodeReset(request_buffer_)); TETHERKIT_ASSIGN_OR_RETURN( const Exchange exchange, @@ -610,11 +642,13 @@ Status StateMachine::Reset() { observer_->OnDeviceReset(complete.addressing_reset); if (complete.addressing_reset) { - // AddressingReset 非零 → 设备丢掉了包过滤与组播表,**必须重发**, - // 否则复位后数据不会再流动。 + // AddressingReset non-zero -> the device dropped the packet filter and the + // multicast table, so we **must resend**, otherwise data will not flow + // again after the reset. // - // (本驱动不维护组播表 —— 包过滤里开了 ALL_MULTICAST,所以只需重发过滤器。 - // 如果将来加了 SET OID_802_3_MULTICAST_LIST,这里也要一并重放。) + // (This driver does not maintain a multicast table -- ALL_MULTICAST is set + // in the packet filter, so only the filter needs resending. If a + // SET OID_802_3_MULTICAST_LIST is ever added, replay it here too.) const std::uint32_t filter = active_packet_filter_ != 0 ? active_packet_filter_ : config_.packet_filter; TETHERKIT_RETURN_IF_ERROR(SetOidUint32(Oid::kGenCurrentPacketFilter, filter)); @@ -628,11 +662,12 @@ Status StateMachine::Reset() { } // ============================================================================= -// 停机 +// Shutdown // ============================================================================= void StateMachine::SendHalt() noexcept { - // HALT_MSG 设备**不会回复**,发完即可认为进入 uninitialized。 + // The device does **not** reply to HALT_MSG; once sent we may consider the + // state uninitialized. const auto written = EncodeHalt(NextRequestId(), request_buffer_); if (!written) { TETHERKIT_WARN_TR(Msg::kRndisEncodeHaltFailed, written.error().ToString()); @@ -641,7 +676,8 @@ void StateMachine::SendHalt() noexcept { if (const auto status = channel_->SendMessage(std::span{request_buffer_.data(), *written}); !status) { - // 停机路径上的失败只记日志 —— 设备可能已经拔掉了,这很正常。 + // Failures on the shutdown path are only logged -- the device may already + // have been unplugged, which is perfectly normal. TETHERKIT_DEBUG_TR(Msg::kRndisSendHaltFailed, status.error().ToString()); return; } @@ -654,9 +690,10 @@ void StateMachine::Stop() { } TransitionTo(State::kHalting); - // 先把包过滤清零让设备停止发数据(规范:filter = 0 会让设备退回 - // RNDIS-initialized),再发 HALT。即使清零失败也要继续发 HALT —— - // 不发的话设备会一直以为主机还在,下次插上时状态不干净。 + // Zero the packet filter first so the device stops sending data (spec: a + // filter of 0 drops the device back to RNDIS-initialized), then send HALT. + // Continue to HALT even if clearing fails -- without it the device keeps + // believing the host is still there, and the next plug-in starts unclean. if (active_packet_filter_ != 0) { if (const auto status = SetOidUint32(Oid::kGenCurrentPacketFilter, 0); !status) { TETHERKIT_DEBUG_TR(Msg::kRndisClearPacketFilterFailed, status.error().ToString()); diff --git a/src/usb/CMakeLists.txt b/src/usb/CMakeLists.txt index f9253a5..6687689 100644 --- a/src/usb/CMakeLists.txt +++ b/src/usb/CMakeLists.txt @@ -1,7 +1,10 @@ -# tk_usb —— USB 传输层 +# tk_usb -- USB transport layer # -# 封装 libusb:设备发现与识别、接口声明、控制通道、中断通知、异步 bulk 传输池。 -# 依赖 tk_rndis 只为拿协议常量(接口签名、控制请求码、通知格式),不含状态机。 +# Wraps libusb: device discovery and identification, interface claiming, +# control channel, interrupt notifications, and the asynchronous bulk transfer +# pool. It depends on tk_rndis only for protocol constants (interface +# signatures, control request codes, notification format) -- not the state +# machine. add_library( tk_usb STATIC diff --git a/src/usb/context.cc b/src/usb/context.cc index 1dfecda..696d141 100644 --- a/src/usb/context.cc +++ b/src/usb/context.cc @@ -9,13 +9,16 @@ namespace tetherkit::usb { namespace { -/// 事件循环单次 handle_events 的阻塞上限。 +/// Upper bound on how long a single handle_events call may block in the event +/// loop. /// -/// 为什么可以用这么长的超时:darwin 后端给所有 bulk transfer 打了 -/// USBI_TRANSFER_OS_HANDLES_TIMEOUT(超时由 IOKit 负责),而 -/// libusb_get_next_timeout 会跳过带该标志的 transfer —— 所以只要在飞的都是 -/// bulk,它恒返回「无超时」,事件线程完全靠 event pipe 唤醒即可。 -/// 这里给 250 ms 只是为了让 RequestStop() 能在一个周期内被看到。 +/// Why such a long timeout is fine: the darwin backend marks every bulk +/// transfer with USBI_TRANSFER_OS_HANDLES_TIMEOUT (IOKit owns the timeout), +/// and libusb_get_next_timeout skips transfers carrying that flag -- so as +/// long as everything in flight is bulk, it always reports "no timeout" and +/// the event thread can rely purely on the event pipe to wake it. +/// The 250 ms here exists only so that RequestStop() is seen within one +/// cycle. constexpr long kEventLoopTimeoutSeconds = 0; constexpr long kEventLoopTimeoutMicros = 250'000; @@ -42,8 +45,9 @@ Context::~Context() { RequestStop(); if (event_thread_.joinable()) { - // libusb_interrupt_event_handler 会往 event pipe 写一个字节,让阻塞在 - // handle_events 里的线程立刻返回。没有它就得等满一个超时周期。 + // libusb_interrupt_event_handler writes one byte to the event pipe so the + // thread blocked in handle_events returns immediately. Without it we + // would have to wait out a full timeout period. if (context_ != nullptr) { ::libusb_interrupt_event_handler(context_); } @@ -86,16 +90,18 @@ void Context::RunEventLoop() noexcept { timeout.tv_usec = kEventLoopTimeoutMicros; while (!stop_requested_.load(std::memory_order_acquire)) { - // 用带超时的版本而非 libusb_handle_events():后者内部超时固定 60 秒, - // 停机响应太慢;也不用 libusb_handle_events_completed(),因为我们的退出 - // 条件是自己的原子标志而非某个 transfer 的完成。 + // Use the timeout-taking variant rather than libusb_handle_events(): the + // latter's internal timeout is fixed at 60 seconds, far too slow for + // shutdown. Not libusb_handle_events_completed() either, because our exit + // condition is our own atomic flag, not some transfer's completion. const int rc = ::libusb_handle_events_timeout_completed(context_, &timeout, nullptr); if (rc == LIBUSB_SUCCESS || rc == LIBUSB_ERROR_INTERRUPTED) { continue; } if (rc == LIBUSB_ERROR_NO_DEVICE) { - // 设备拔了。不是事件循环的错,交给上层的重连逻辑处理,这里继续跑。 + // The device was unplugged. Not the event loop's fault; upper-level + // reconnect logic handles it, so keep running here. TETHERKIT_DEBUG_TR(Msg::kUsbEventLoopNoDevice); continue; } diff --git a/src/usb/data_channel.cc b/src/usb/data_channel.cc index e95f7cc..b0a5bad 100644 --- a/src/usb/data_channel.cc +++ b/src/usb/data_channel.cc @@ -12,16 +12,18 @@ namespace tetherkit::usb { namespace { -/// 传输缓冲的对齐字节数。 +/// Alignment in bytes for the transfer buffers. /// -/// darwin 后端不提供零拷贝 DMA 缓冲(libusb_dev_mem_alloc 返回 NULL), -/// 只能自己分配。按系统页大小对齐能减少 IOKit 为该缓冲建立 DMA 描述符时的 -/// 内存分段数量。Apple Silicon 上 hw.pagesize 是 16384。 +/// The darwin backend provides no zero-copy DMA buffers (libusb_dev_mem_alloc +/// returns NULL), so we must allocate our own. Aligning to the system page +/// size reduces the number of memory segments IOKit has to build DMA +/// descriptors for this buffer out of. On Apple Silicon, hw.pagesize is +/// 16384. [[nodiscard]] std::size_t QueryPageSize() { std::int64_t value = 0; std::size_t size = sizeof(value); if (::sysctlbyname("hw.pagesize", &value, &size, nullptr, 0) != 0 || value <= 0) { - return 16384; // Apple Silicon 的实际值,作为兜底 + return 16384; // the actual Apple Silicon value, as a backstop } return static_cast(value); } @@ -29,7 +31,7 @@ namespace { } // namespace // ============================================================================= -// 构造与析构 +// Construction and destruction // ============================================================================= Result> UsbDataChannel::Create( @@ -40,7 +42,8 @@ Result> UsbDataChannel::Create( channel->parameters_ = parameters; channel->config_ = config; - // TX 缓冲不能超过设备宣称的 MaxTransferSize —— 超了设备会拒收或截断。 + // The TX buffer must not exceed the device's advertised MaxTransferSize -- + // beyond it the device will reject or truncate. channel->tx_transfer_bytes_ = std::min(config.tx_transfer_bytes, parameters.device_max_transfer_size); if (channel->tx_transfer_bytes_ < rndis::kPacketMsgHeaderBytes + parameters.mtu + @@ -55,7 +58,7 @@ Result> UsbDataChannel::Create( TETHERKIT_RETURN_IF_ERROR(channel->AllocatePool(channel->tx_pool_, config.tx_transfer_count, channel->tx_transfer_bytes_)); - // TX 空闲槽位队列:初始全部空闲。 + // TX free-slot queue: everything starts out free. channel->tx_free_slots_ = std::make_unique>(config.tx_transfer_count); for (std::uint32_t i = 0; i < config.tx_transfer_count; ++i) { if (!channel->tx_free_slots_->TryPush(i)) { @@ -74,7 +77,8 @@ Result> UsbDataChannel::Create( } UsbDataChannel::~UsbDataChannel() { - // Shutdown 是幂等的;这里兜底一次,防止调用方忘了。 + // Shutdown is idempotent; call it once here as a backstop in case the + // caller forgets. Shutdown(); FreePool(rx_pool_); FreePool(tx_pool_); @@ -92,7 +96,8 @@ Status UsbDataChannel::AllocatePool(std::vector& pool, std::uint32_t count slot.buffer_bytes = buffer_bytes; void* raw = nullptr; - // 长度也向上取整到对齐边界,避免尾部跨页。 + // Round the length up to the alignment boundary too, so the tail does not + // straddle a page. const std::size_t allocation = ((buffer_bytes + alignment - 1) / alignment) * alignment; if (::posix_memalign(&raw, alignment, allocation) != 0 || raw == nullptr) { @@ -115,7 +120,7 @@ void UsbDataChannel::FreePool(std::vector& pool) noexcept { ::libusb_free_transfer(slot.transfer); slot.transfer = nullptr; } - // posix_memalign 分配的内存必须用 free 释放(不能用 delete)。 + // Memory from posix_memalign must be released with free (not delete). // NOLINTNEXTLINE(cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory) std::free(slot.buffer); slot.buffer = nullptr; @@ -124,7 +129,7 @@ void UsbDataChannel::FreePool(std::vector& pool) noexcept { } // ============================================================================= -// 接收路径(设备 → 主机) +// Receive path (device -> host) // ============================================================================= Status UsbDataChannel::StartReceiving(FrameRing& rx_ring, DirectionCounters& rx_counters) { @@ -145,13 +150,16 @@ Status UsbDataChannel::SubmitReceive(Slot& slot) { &UsbDataChannel::ReceiveCallbackTrampoline, &slot, static_cast(config_.rx_timeout_millis)); - // **刻意不设 LIBUSB_TRANSFER_SHORT_NOT_OK。** - // 短包在 bulk IN 上是完全正常的语义(设备只有半个缓冲的数据就发短包结束传输)。 - // 设了这个标志会让 io.c 把「COMPLETED 且 actual_length != 请求长度」重判为 - // LIBUSB_TRANSFER_ERROR —— 那样几乎每个传输都会「失败」。 + // **Deliberately do not set LIBUSB_TRANSFER_SHORT_NOT_OK.** + // A short packet is perfectly normal semantics on bulk IN (the device sends + // a short packet to end the transfer when it has only half a buffer of + // data). Setting the flag makes io.c reclassify "COMPLETED with + // actual_length != requested length" as LIBUSB_TRANSFER_ERROR -- at which + // point nearly every transfer would "fail". slot.transfer->flags = 0; - // 先递增在飞计数再提交:否则回调可能在递增之前就跑完,导致计数下溢。 + // Increment the in-flight count before submitting: otherwise the callback + // may finish before the increment and the counter underflows. outstanding_.fetch_add(1, std::memory_order_acq_rel); const int rc = ::libusb_submit_transfer(slot.transfer); @@ -168,12 +176,13 @@ void UsbDataChannel::ReceiveCallbackTrampoline(::libusb_transfer* transfer) { } void UsbDataChannel::OnReceiveComplete(Slot& slot) noexcept { - // ⚠️ 本函数在 libusb **事件线程**上执行,并且持有 ctx->event_waiters_lock。 - // 因此这里: - // * 绝不能调用同步 API(会返回 LIBUSB_ERROR_BUSY); - // * 绝不能做阻塞 I/O(会拖住所有等同步传输的线程); - // * 只做「解包 + memcpy 进无锁队列 + 立刻 resubmit」。 - // 真正的 BPF write() 由另一个线程负责。 + // Warning: this runs on the libusb **event thread** while holding + // ctx->event_waiters_lock. Therefore here: + // * never call the synchronous APIs (they return LIBUSB_ERROR_BUSY); + // * never do blocking I/O (it would stall every thread waiting on + // synchronous transfers); + // * only "unwrap + memcpy into the lock-free queue + resubmit at once". + // The real BPF write() belongs to another thread. const ::libusb_transfer* transfer = slot.transfer; bool should_resubmit = true; @@ -181,7 +190,8 @@ void UsbDataChannel::OnReceiveComplete(Slot& slot) noexcept { case LIBUSB_TRANSFER_COMPLETED: { const auto received = static_cast(transfer->actual_length); if (received > 0 && rx_ring_ != nullptr) { - // 批量发布:整个传输里的所有帧只做一次 release store。 + // Batched publish: every frame in the transfer costs a single release + // store. auto batch = rx_ring_->BeginBatchWrite(); rndis::PacketMessageReader reader( std::span{slot.buffer, received}, rx_ring_->MaxFrameBytes()); @@ -192,7 +202,8 @@ void UsbDataChannel::OnReceiveComplete(Slot& slot) noexcept { const rndis::ReadOutcome outcome = reader.Next(frame); if (outcome == rndis::ReadOutcome::kFrame) { if (!batch.Push(frame)) [[unlikely]] { - // 下游队列满 —— BPF 写线程跟不上。丢弃并计数,不能阻塞在这里。 + // Downstream queue full -- the BPF writer thread cannot keep + // up. Drop and count; never block here. rx_counters_->AddDroppedFull(); continue; } @@ -219,9 +230,11 @@ void UsbDataChannel::OnReceiveComplete(Slot& slot) noexcept { } case LIBUSB_TRANSFER_STALL: - // 端点 halt。清掉再继续 —— libusb_clear_halt 走同步 IOKit 调用但**没有** - // usbi_handling_events 守卫,在回调里调用是安全的(代价是阻塞事件线程 - // 几十微秒到毫秒级,所以只在真 STALL 时做)。 + // Endpoint halt. Clear it and continue -- libusb_clear_halt is a + // synchronous IOKit call but has **no** usbi_handling_events guard, so + // calling it from a callback is safe (at the cost of blocking the event + // thread for tens of microseconds to milliseconds, hence only on a real + // STALL). rx_counters_->AddIoError(); TETHERKIT_WARN_TR(Msg::kUsbBulkInStall); if (const auto status = device_->ClearHalt(device_->BulkInEndpoint()); !status) { @@ -231,18 +244,20 @@ void UsbDataChannel::OnReceiveComplete(Slot& slot) noexcept { break; case LIBUSB_TRANSFER_CANCELLED: - // 停机路径。**不要** resubmit。 + // Shutdown path. Do **not** resubmit. should_resubmit = false; break; case LIBUSB_TRANSFER_NO_DEVICE: - // 设备拔了。停止重提交,让在飞计数收敛,由上层重连逻辑处理。 + // Device unplugged. Stop resubmitting so the in-flight count + // converges; upper-layer reconnection logic takes it from here. should_resubmit = false; TETHERKIT_INFO_TR(Msg::kUsbBulkInDeviceGone); break; case LIBUSB_TRANSFER_TIMED_OUT: - // rx_timeout_millis 默认 0(无限),正常不该出现。出现了就继续提交。 + // rx_timeout_millis defaults to 0 (infinite), so this should not happen + // normally. If it does, keep submitting. rx_counters_->AddIoError(); break; @@ -259,17 +274,18 @@ void UsbDataChannel::OnReceiveComplete(Slot& slot) noexcept { } if (should_resubmit) { - // 官方保证:在回调里直接 resubmit 同一个 transfer 是安全的 - // (usbi_handle_transfer_completion 在调回调前已把它移出 flying list - // 并清了 IN_FLIGHT 标志,且不持 itransfer->lock)。 + // Officially guaranteed safe: resubmitting the same transfer directly + // inside the callback is fine (usbi_handle_transfer_completion has + // already removed it from the flying list and cleared the IN_FLIGHT flag + // before invoking the callback, and does not hold itransfer->lock). const int rc = ::libusb_submit_transfer(slot.transfer); if (rc == LIBUSB_SUCCESS) { - return; // 在飞计数保持不变:一进一出 + return; // in-flight count unchanged: one out, one in } TETHERKIT_WARN_TR(Msg::kUsbBulkInResubmitFailed, ::libusb_error_name(rc)); } - // 该传输就此退出飞行。通知可能正在等待归零的 Shutdown()。 + // The transfer has left flight. Wake any Shutdown() waiting for zero. if (outstanding_.fetch_sub(1, std::memory_order_acq_rel) == 1) { const std::lock_guard guard(drain_mutex_); drain_condition_.notify_all(); @@ -277,7 +293,7 @@ void UsbDataChannel::OnReceiveComplete(Slot& slot) noexcept { } // ============================================================================= -// 发送路径(主机 → 设备) +// Send path (host -> device) // ============================================================================= bool UsbDataChannel::CanSend() const noexcept { @@ -297,13 +313,14 @@ Result UsbDataChannel::SendFrames(std::span frames while (outcome.consumed < frames.size()) { std::uint32_t slot_index = 0; if (!tx_free_slots_->TryPop(slot_index)) { - // 没有空闲传输槽位 —— 这就是背压。如实返回已消费数,调用方应 - // WaitForSendCapacity() 后重试剩余帧,而不是丢弃。 + // No free transfer slot -- this is backpressure. Return the consumed + // count truthfully; the caller should WaitForSendCapacity() and retry + // the remaining frames rather than dropping them. break; } Slot& slot = tx_pool_[slot_index]; - // 把尽可能多的帧聚合进这一个传输。 + // Aggregate as many frames as possible into this one transfer. rndis::PacketMessageWriter writer( std::span{slot.buffer, slot.buffer_bytes}, rndis::PacketMessageWriter::Limits{ @@ -313,28 +330,30 @@ Result UsbDataChannel::SendFrames(std::span frames }, device_->BulkMaxPacketSize()); - std::uint32_t scanned = 0; // 从输入消费掉的帧数(含被跳过的短帧) - std::uint32_t appended = 0; // 真正装进本传输的帧数 + std::uint32_t scanned = 0; // frames consumed from the input (including skipped short ones) + std::uint32_t appended = 0; // frames actually packed into this transfer bool append_failed = false; while (outcome.consumed + scanned < frames.size()) { const FrameView& frame = frames[outcome.consumed + scanned]; if (frame.length < kMinEthernetFrameBytes) [[unlikely]] { - // 非法短帧:跳过并如实计入 skipped —— 它没有被发出。 + // Illegal short frame: skip it and count it truthfully as skipped -- + // it was never sent. ++scanned; ++outcome.skipped; continue; } if (!writer.TryAppend(frame.Bytes())) { append_failed = true; - break; // 本批装满了(受字节数或包数上限),或单帧超长 + break; // this chunk is full (byte or packet cap) or the frame is oversized } ++scanned; ++appended; } if (writer.Empty()) { - // 一帧都没装进去。要么这一段全是被跳过的短帧,要么第一个合法帧就超过了 - // 设备的 MaxTransferSize —— 后者必须跳过并前进,否则会死循环。 + // Not a single frame fit. Either this run was all skipped short frames, + // or the first legal frame exceeds the device's MaxTransferSize -- the + // latter must be skipped and advanced past, or we spin forever. outcome.consumed += scanned; if (append_failed && outcome.consumed < frames.size()) { ++outcome.consumed; @@ -368,8 +387,9 @@ Result UsbDataChannel::SendFrames(std::span frames return std::unexpected(Error::FromLibUsb(rc, Tr(Msg::kUsbSubmitBulkOutFailed))); } - // 帧数与字节数由**桥接层**统计(它是 TX 计数器的唯一写者)。这里只记 - // 供诊断用的聚合效果。 + // Frames and bytes are counted by the **bridge** (it is the only writer + // of the TX counters). Here we only log the aggregation effect for + // diagnostics. TETHERKIT_TRACE_TR(Msg::kUsbBulkOutSubmitted, message_count, payload_bytes, transfer_bytes); outcome.consumed += scanned; outcome.sent_frames += appended; @@ -393,7 +413,7 @@ void UsbDataChannel::SendCallbackTrampoline(::libusb_transfer* transfer) { } void UsbDataChannel::OnSendComplete(Slot& slot) noexcept { - // 同样在 libusb 事件线程上,同样不能阻塞。 + // Also on the libusb event thread; also must not block. switch (slot.transfer->status) { case LIBUSB_TRANSFER_COMPLETED: break; @@ -416,13 +436,16 @@ void UsbDataChannel::OnSendComplete(Slot& slot) noexcept { break; } - // 归还槽位。停机时不归还也无所谓 —— 反正不会再有人取。 + // Return the slot. Not returning it during shutdown does not matter -- + // nobody will take one anyway. if (!shutting_down_.load(std::memory_order_acquire)) { if (!tx_free_slots_->TryPush(slot.index)) { TETHERKIT_ERROR_TR(Msg::kUsbReturnTxSlotFailed, slot.index); } - // 唤醒可能在 WaitForSendCapacity 里等槽位的 TX 线程。空临界区惯用法: - // 取一下锁保证等待方不会在「查完谓词、还没睡下」的窗口里漏掉这次通知。 + // Wake a TX thread that may be waiting for a slot in + // WaitForSendCapacity(). Empty-critical-section idiom: take the lock + // briefly so the waiter cannot miss this notification in the "predicate + // checked, not yet asleep" window. { const std::lock_guard guard(send_capacity_mutex_); } send_capacity_cv_.notify_one(); } @@ -434,29 +457,35 @@ void UsbDataChannel::OnSendComplete(Slot& slot) noexcept { } // ============================================================================= -// 拆除 +// Teardown // ============================================================================= void UsbDataChannel::Shutdown() { if (shutdown_complete_) { return; } - // ⚠️ 本函数**绝对不能**从 libusb 事件线程调用:下面要等在飞计数归零, - // 而递减它的回调正是在事件线程上跑的 —— 在那里等就是自己等自己,必然死锁。 - // 正确的调用者是控制线程或主线程。 - - // 1. 置停机标志。回调看到它就不再 resubmit,在飞数开始自然收敛。 - // 同时唤醒可能还在 WaitForSendCapacity 里等槽位的线程(正常拆除顺序下 - // TX 线程此时已被 join,这里是防御性兜底 —— 比如测试直接驱动通道时)。 + // Warning: this function must **never** be called from the libusb event + // thread: below we wait for the in-flight count to reach zero, and the + // callbacks decrementing it run on that very thread -- waiting there is + // waiting on ourselves, a guaranteed deadlock. The right callers are the + // control thread or the main thread. + + // 1. Set the shutdown flag. Callbacks see it and stop resubmitting, so the + // in-flight count starts converging on its own. Also wake any thread + // still waiting for a slot in WaitForSendCapacity() (under the normal + // teardown order the TX thread is already joined by here; this is a + // defensive backstop -- e.g. when tests drive the channel directly). shutting_down_.store(true, std::memory_order_release); { const std::lock_guard guard(send_capacity_mutex_); } send_capacity_cv_.notify_all(); - // 2. 取消在飞传输。 + // 2. Cancel the in-flight transfers. // - // darwin 上 libusb_cancel_transfer 是 AbortPipe,会取消**该端点上所有**在飞 - // 传输,所以每个端点其实只需调一次;这里逐个调是幂等且更清晰的写法。 - // LIBUSB_ERROR_NOT_FOUND 表示它本来就不在飞,属于正常情况。 + // On darwin libusb_cancel_transfer is AbortPipe and cancels **every** + // in-flight transfer on that endpoint, so one call per endpoint would + // technically suffice; calling per slot here is idempotent and clearer. + // LIBUSB_ERROR_NOT_FOUND means it was not in flight to begin with, which is + // normal. for (Slot& slot : rx_pool_) { if (slot.transfer != nullptr) { const int rc = ::libusb_cancel_transfer(slot.transfer); @@ -474,8 +503,9 @@ void UsbDataChannel::Shutdown() { } } - // 3. 等在飞计数归零。**这一步是避免 use-after-free 的关键** —— - // 必须等到每个回调都跑完,才能释放 transfer 与缓冲。 + // 3. Wait for the in-flight count to reach zero. **This is the key step + // for avoiding use-after-free** -- every callback must have run before + // we free the transfers and buffers. { constexpr auto kDrainTimeout = std::chrono::seconds(5); std::unique_lock lock(drain_mutex_); @@ -483,12 +513,13 @@ void UsbDataChannel::Shutdown() { return outstanding_.load(std::memory_order_acquire) == 0; }); if (!drained) { - // 超时。这时释放缓冲是危险的(回调可能还会访问),所以**故意泄漏** —— - // 泄漏几百 KB 远好于 use-after-free 崩溃。 + // Timeout. Freeing the buffers here would be dangerous (callbacks may + // still touch them), so **deliberately leak** -- leaking a few hundred + // KB beats a use-after-free crash. const std::uint32_t stuck = outstanding_.load(std::memory_order_acquire); TETHERKIT_ERROR_TR(Msg::kUsbTransferReclaimTimeout, stuck, (stuck * config_.rx_transfer_bytes) / 1024); - // 把 transfer 指针清空,让 FreePool 跳过它们。 + // Null the transfer pointers so FreePool skips them. for (Slot& slot : rx_pool_) { slot.transfer = nullptr; slot.buffer = nullptr; diff --git a/src/usb/device.cc b/src/usb/device.cc index 180c9fd..25061d3 100644 --- a/src/usb/device.cc +++ b/src/usb/device.cc @@ -13,38 +13,42 @@ namespace tetherkit::usb { namespace { -/// CDC 功能描述符类型(bDescriptorType = CS_INTERFACE)。 +/// CDC functional descriptor type (bDescriptorType = CS_INTERFACE). constexpr std::uint8_t kCsInterfaceDescriptorType = 0x24; -/// CDC Union 功能描述符子类型。 +/// CDC Union functional descriptor subtype. constexpr std::uint8_t kUnionFunctionalSubtype = 0x06; -/// CDC ACM 功能描述符子类型(用于识别真 cdc-acm 调制解调器)。 +/// CDC ACM functional descriptor subtype (used to spot a genuine cdc-acm +/// modem). constexpr std::uint8_t kAcmFunctionalSubtype = 0x02; -/// Android quirk 的硬编码接口号(与 Linux 的 android_rndis_quirk 一致)。 +/// Hard-coded interface numbers for the Android quirk (matching Linux's +/// android_rndis_quirk). constexpr std::uint8_t kAndroidQuirkControlInterface = 0; constexpr std::uint8_t kAndroidQuirkDataInterface = 1; -/// 取接口的 class/subclass/protocol 三元组。 +/// Take the interface's class/subclass/protocol triple. [[nodiscard]] rndis::InterfaceSignature SignatureOf( const ::libusb_interface_descriptor& descriptor) { return rndis::InterfaceSignature{descriptor.bInterfaceClass, descriptor.bInterfaceSubClass, descriptor.bInterfaceProtocol}; } -/// 在接口的 extra 描述符里找 CDC 功能描述符。 +/// Look for a CDC functional descriptor in the interface's extra descriptors. /// -/// 返回 nullptr 表示没找到。`out_length` 输出该描述符的总长度。 +/// Returns nullptr when not found. `out_length` receives the total length of +/// that descriptor. [[nodiscard]] const std::uint8_t* FindCdcFunctional(const ::libusb_interface_descriptor& descriptor, std::uint8_t subtype, std::uint8_t& out_length) { const std::uint8_t* cursor = descriptor.extra; int remaining = descriptor.extra_length; - // CDC 功能描述符链的通用格式:[bLength][bDescriptorType][bDescriptorSubtype][...] + // General format of a CDC functional descriptor chain: + // [bLength][bDescriptorType][bDescriptorSubtype][...] while (remaining >= 3) { const std::uint8_t length = cursor[0]; if (length < 3 || length > remaining) { - break; // 描述符链损坏,停止解析 + break; // descriptor chain is corrupt; stop parsing } if (cursor[1] == kCsInterfaceDescriptorType && cursor[2] == subtype) { out_length = length; @@ -57,11 +61,13 @@ constexpr std::uint8_t kAndroidQuirkDataInterface = 1; return nullptr; } -/// 判断是不是「伪 RNDIS」—— 真正的 CDC ACM 调制解调器。 +/// Decide whether this is a "fake RNDIS" -- a real CDC ACM modem. /// -/// 判据:class == 0x02 且带**非零** bmCapabilities 的 ACM 功能描述符。 -/// **这条检查只能对 class == 0x02 生效** —— 无线类(0xE0)的 RNDIS function -/// 会把 bmCapabilities 字段挪作自用,对它套这条规则会把真 RNDIS 误判掉。 +/// Criteria: class == 0x02 plus an ACM functional descriptor with a +/// **non-zero** bmCapabilities. **This check must only apply to +/// class == 0x02** -- the wireless-class (0xE0) RNDIS function repurposes the +/// bmCapabilities field, so applying this rule there would misclassify genuine +/// RNDIS devices. [[nodiscard]] bool LooksLikeRealAcmModem(const ::libusb_interface_descriptor& descriptor) { if (descriptor.bInterfaceClass != 0x02) { return false; @@ -71,13 +77,14 @@ constexpr std::uint8_t kAndroidQuirkDataInterface = 1; if (acm == nullptr || length < 4) { return false; } - // 布局:[bLength][CS_INTERFACE][ACM subtype][bmCapabilities] + // Layout: [bLength][CS_INTERFACE][ACM subtype][bmCapabilities] return acm[3] != 0; } -/// 从 CDC Union 功能描述符里取第一个 slave 接口号。 +/// Take the first slave interface number from a CDC Union functional +/// descriptor. /// -/// 布局:[bLength][CS_INTERFACE][UNION subtype][bMasterInterface][bSlaveInterface0]... +/// Layout: [bLength][CS_INTERFACE][UNION subtype][bMasterInterface][bSlaveInterface0]... [[nodiscard]] bool TryReadUnionSlaveInterface(const ::libusb_interface_descriptor& descriptor, std::uint8_t& out_slave) { std::uint8_t length = 0; @@ -89,7 +96,8 @@ constexpr std::uint8_t kAndroidQuirkDataInterface = 1; return true; } -/// 某个配置里是否存在指定接口号,且它的签名是 RNDIS 数据接口。 +/// Whether a given interface number exists in a configuration and its +/// signature is the RNDIS data interface. [[nodiscard]] bool HasDataInterface(const ::libusb_config_descriptor& config, std::uint8_t interface_number) { for (std::uint8_t i = 0; i < config.bNumInterfaces; ++i) { @@ -115,7 +123,7 @@ std::string DeviceCandidate::Describe() const { } // ============================================================================= -// 设备发现 +// Device discovery // ============================================================================= Result> FindRndisDevices(const Context& context, @@ -126,7 +134,7 @@ Result> FindRndisDevices(const Context& context, return std::unexpected( Error::FromLibUsb(static_cast(count), Tr(Msg::kUsbGetDeviceListFailed))); } - // RAII 释放设备列表(unref_devices = 1)。 + // RAII free for the device list (unref_devices = 1). const std::unique_ptr<::libusb_device*, void (*)(::libusb_device**)> list_guard( raw_list, [](::libusb_device** list) { ::libusb_free_device_list(list, 1); }); @@ -152,7 +160,9 @@ Result> FindRndisDevices(const Context& context, continue; } - // 逐个配置找 RNDIS 通信接口。多数设备只有一个配置,但也有例外。 + // Look through every configuration for the RNDIS communication + // interface. Most devices have a single configuration, but there are + // exceptions. for (std::uint8_t config_index = 0; config_index < device_descriptor.bNumConfigurations; ++config_index) { ::libusb_config_descriptor* config = nullptr; @@ -185,7 +195,7 @@ Result> FindRndisDevices(const Context& context, candidate.control_interface = descriptor.bInterfaceNumber; candidate.signature = signature; - // 找配对的数据接口:优先 CDC Union 描述符。 + // Find the paired data interface: prefer the CDC Union descriptor. std::uint8_t slave = 0; bool resolved = false; if (TryReadUnionSlaveInterface(descriptor, slave) && @@ -194,10 +204,12 @@ Result> FindRndisDevices(const Context& context, resolved = true; } - // Android quirk:许多 Android 设备的 CDC Union 指向不存在的接口号, - // 或者干脆没有 CDC 功能描述符。Linux 的 android_rndis_quirk 在这种 - // 情况下硬编码「接口 0 = control、接口 1 = data」,并要求被 probe 的 - // 通信接口确实是 0。这里照做。 + // Android quirk: many Android devices' CDC Union points at an + // interface number that does not exist, or there is no CDC + // functional descriptor at all. Linux's android_rndis_quirk + // hard-codes "interface 0 = control, interface 1 = data" in that + // case, and requires the probed communication interface to really + // be 0. We do the same here. if (!resolved && candidate.control_interface == kAndroidQuirkControlInterface && HasDataInterface(*config, kAndroidQuirkDataInterface)) { candidate.data_interface = kAndroidQuirkDataInterface; @@ -211,16 +223,18 @@ Result> FindRndisDevices(const Context& context, continue; } - // DEBUG 而不是 INFO:枚举是被周期性调用的(GUI 每 2 秒扫一次), - // 这句话在 INFO 级别会把日志刷成一整列重复。「发现了什么」由调用方 - // 决定怎么呈现 —— CLI 自己打印列表,GUI 显示在设备卡里。 + // DEBUG rather than INFO: enumeration is called periodically (the + // GUI scans every 2 seconds), and at INFO this line would flood the + // log with duplicates. What was "discovered" is the caller's call to + // present -- the CLI prints its own list, the GUI shows it on the + // device card. TETHERKIT_DEBUG_TR( Msg::kUsbDeviceFound, candidate.Describe(), candidate.control_interface, candidate.data_interface, signature.interface_class, signature.interface_subclass, signature.interface_protocol, candidate.used_android_quirk ? Text(Msg::kUsbViaAndroidQuirk) : std::string_view{}); candidates.push_back(candidate); - // 一个设备只取第一个匹配的通信接口。 + // Take only the first matching communication interface per device. goto next_device; } } @@ -246,7 +260,8 @@ Result> Device::Open(const Context& context, const std::unique_ptr<::libusb_device*, void (*)(::libusb_device**)> list_guard( raw_list, [](::libusb_device** list) { ::libusb_free_device_list(list, 1); }); - // 按总线 + 地址定位设备(比 VID:PID 精确,能区分同型号的多个设备)。 + // Locate the device by bus + address (more precise than VID:PID; it can + // tell apart several units of the same model). ::libusb_device* target = nullptr; for (ssize_t index = 0; index < count; ++index) { if (::libusb_get_bus_number(raw_list[index]) == candidate.bus_number && @@ -271,10 +286,12 @@ Result> Device::Open(const Context& context, Error::FromLibUsb(open_rc, Tr(Msg::kUsbOpenFailed, device->description_))); } - // **刻意不调用 libusb_set_auto_detach_kernel_driver** —— 见头文件说明: - // 在 macOS 上它会触发破坏性的整设备重新枚举,而 RNDIS 接口本来就没人占。 + // **Deliberately do not call libusb_set_auto_detach_kernel_driver** -- see + // the header notes: on macOS it triggers a destructive whole-device + // re-enumeration, and nothing holds the RNDIS interface anyway. - // 声明两个接口。通信接口在前,因为控制通道要用它。 + // Claim both interfaces. The communication interface first, because the + // control channel needs it. const auto claim = [&device](std::uint8_t interface_number, std::string_view role, bool& claimed_flag) -> Status { const int rc = ::libusb_claim_interface(device->handle_, interface_number); @@ -299,7 +316,7 @@ Result> Device::Open(const Context& context, claim(candidate.data_interface, Text(Msg::kUsbDataInterface), device->data_interface_claimed_)); - // 解析端点。 + // Resolve the endpoints. ::libusb_config_descriptor* config = nullptr; const int config_rc = ::libusb_get_active_config_descriptor(target, &config); if (config_rc != LIBUSB_SUCCESS) { @@ -324,12 +341,14 @@ Device::~Device() { if (handle_ == nullptr) { return; } - // 顺序:释放接口 → 关闭句柄。 + // Order: release interfaces -> close the handle. // - // ⚠️ 前置条件:所有异步 transfer 必须已经全部回收。libusb_close **不会**帮你 - // 回收在飞 transfer(只是把 dev_handle 置空并打日志),之后 IOKit 中止仍会让 - // 回调在 libusb 内部线程上跑 —— 那时若 transfer 已被 free 就是 UAF。 - // 保证这一点是 TransferPool 的责任,见它的 Shutdown()。 + // Warning: precondition -- every async transfer must already have been + // reclaimed. libusb_close does **not** reclaim in-flight transfers for you + // (it only nulls dev_handle and logs), and the subsequent IOKit abort still + // lets callbacks run on libusb's internal thread -- by then a freed + // transfer is a UAF. Ensuring this is TransferPool's responsibility; see + // its Shutdown(). if (data_interface_claimed_) { ::libusb_release_interface(handle_, candidate_.data_interface); } @@ -386,7 +405,8 @@ Status Device::ResolveEndpoints(const ::libusb_config_descriptor& config) { if (bulk_max_packet_size_ == 0) { return std::unexpected(Error::Generic(Tr(Msg::kUsbBulkMaxPacketSizeZero))); } - // 中断端点缺失是合法的:Linux 的 host 驱动干脆完全忽略它,改为轮询控制端点。 + // A missing interrupt endpoint is legal: Linux's host driver simply ignores + // it and polls the control endpoint instead. return Ok(); } @@ -423,7 +443,8 @@ Status Device::ClearHalt(std::uint8_t endpoint) { UsbControlChannel::UsbControlChannel(Device& device, std::uint32_t timeout_millis) : device_(&device), timeout_millis_(timeout_millis) { response_buffer_.resize(rndis::kControlBufferBytes); - // 缓冲取端点实际的 wMaxPacketSize(RNDIS 通知是 8 字节,但别假死这个值)。 + // Size the buffer from the endpoint's real wMaxPacketSize (an RNDIS + // notification is 8 bytes, but do not hard-code that value). notification_buffer_.resize( std::max(rndis::kNotificationBytes, device.InterruptMaxPacketSize())); } @@ -437,10 +458,11 @@ UsbControlChannel::~UsbControlChannel() { } // ============================================================================= -// 异步中断通知监听 +// Asynchronous interrupt notification listener // -// 详见 device.h 里那段说明:同步中断传输在 macOS 上会永久阻塞, -// 因为 darwin 用的是 ReadPipeAsync(无超时变体),timeout 参数不被遵守。 +// See the notes in device.h: a synchronous interrupt transfer blocks forever +// on macOS because darwin uses ReadPipeAsync (the no-timeout variant), so the +// timeout argument is not honored. // ============================================================================= Status UsbControlChannel::StartNotificationListener() { @@ -457,8 +479,10 @@ Status UsbControlChannel::StartNotificationListener() { return std::unexpected(Error::Generic(Tr(Msg::kUsbAllocInterruptTransferFailed))); } - // timeout 传 0:中断端点上本来就是「有事才来」,无限等待正是我们要的语义。 - // 这里不会卡住任何线程 —— 完成回调跑在 libusb 事件线程上。 + // Pass timeout 0: an interrupt endpoint is "only when something happens" + // anyway, so an infinite wait is exactly the semantics we want. No thread + // gets stuck here -- the completion callback runs on the libusb event + // thread. ::libusb_fill_interrupt_transfer( notification_transfer_, device_->Handle(), device_->InterruptInEndpoint(), reinterpret_cast(notification_buffer_.data()), @@ -488,8 +512,10 @@ void UsbControlChannel::StopNotificationListener() { } } - // 等回调回来。⚠️ 与数据通道同理:**不能从 libusb 事件线程调用本函数**, - // 否则就是自己等自己。超时后不释放 transfer(宁可泄漏也不 use-after-free)。 + // Wait for the callback to come back. Warning: same rule as the data + // channel: **never call this function from the libusb event thread**, or we + // would be waiting on ourselves. On timeout the transfer is not freed (a + // leak beats a use-after-free). constexpr int kMaxWaitMillis = 2000; for (int waited = 0; notification_in_flight_.load(std::memory_order_acquire) && waited < kMaxWaitMillis; @@ -498,7 +524,7 @@ void UsbControlChannel::StopNotificationListener() { } if (notification_in_flight_.load(std::memory_order_acquire)) { TETHERKIT_ERROR_TR(Msg::kUsbInterruptReclaimTimeout); - notification_transfer_ = nullptr; // 故意泄漏 + notification_transfer_ = nullptr; // deliberate leak } } @@ -507,7 +533,7 @@ void UsbControlChannel::NotificationCallbackTrampoline(::libusb_transfer* transf } void UsbControlChannel::OnNotificationComplete() noexcept { - // 本函数在 libusb 事件线程上执行,只做极轻的工作。 + // Runs on the libusb event thread; only very light work here. const ::libusb_transfer* transfer = notification_transfer_; bool resubmit = true; @@ -530,8 +556,10 @@ void UsbControlChannel::OnNotificationComplete() noexcept { break; case LIBUSB_TRANSFER_STALL: - // 中断端点 STALL:清掉后继续。清不掉就放弃监听,退化为轮询控制端点 - // (Linux 的 host 驱动本来就完全不用中断端点,所以这不致命)。 + // Interrupt endpoint STALL: clear it and continue. If it cannot be + // cleared, give up listening and fall back to polling the control + // endpoint (Linux's host driver does not use the interrupt endpoint at + // all, so this is not fatal). if (const auto status = device_->ClearHalt(device_->InterruptInEndpoint()); !status) { TETHERKIT_WARN_TR(Msg::kUsbInterruptHaltClearFailed, status.error().ToString()); resubmit = false; @@ -548,7 +576,7 @@ void UsbControlChannel::OnNotificationComplete() noexcept { resubmit = false; } if (resubmit && ::libusb_submit_transfer(notification_transfer_) == LIBUSB_SUCCESS) { - return; // 仍在飞 + return; // still in flight } notification_in_flight_.store(false, std::memory_order_release); } @@ -559,12 +587,14 @@ Status UsbControlChannel::SendMessage(std::span message) { Error::Generic(Tr(Msg::kUsbControlMessageLengthInvalid, message.size()))); } - // SEND_ENCAPSULATED_COMMAND:bmRequestType=0x21(OUT|Class|Interface), - // bRequest=0x00, wValue=0, wIndex=通信类接口号, wLength=消息长度。 + // SEND_ENCAPSULATED_COMMAND: bmRequestType=0x21 (OUT|Class|Interface), + // bRequest=0x00, wValue=0, wIndex=communication interface number, + // wLength=message length. const int transferred = ::libusb_control_transfer( device_->Handle(), rndis::kControlOutRequestType, rndis::kRequestSendEncapsulatedCommand, /*wValue=*/0, device_->Candidate().control_interface, - // libusb 的 C 接口要 unsigned char*,此处不修改内容。 + // libusb's C API wants unsigned char*; the contents are not modified + // here. const_cast(reinterpret_cast(message.data())), static_cast(message.size()), timeout_millis_); @@ -579,11 +609,12 @@ Status UsbControlChannel::SendMessage(std::span message) { } Result> UsbControlChannel::ReceiveMessage() { - // 每次读之前清零:设备可能只写一部分,残留的旧数据会让解析器读到幻影字段。 + // Zero the buffer before every read: the device may write only part of it, + // and leftover stale data would let the parser read phantom fields. std::memset(response_buffer_.data(), 0, response_buffer_.size()); - // GET_ENCAPSULATED_RESPONSE:bmRequestType=0xA1(IN|Class|Interface), - // bRequest=0x01, wValue=0, wIndex=通信类接口号。 + // GET_ENCAPSULATED_RESPONSE: bmRequestType=0xA1 (IN|Class|Interface), + // bRequest=0x01, wValue=0, wIndex=communication interface number. const int transferred = ::libusb_control_transfer( device_->Handle(), rndis::kControlInRequestType, rndis::kRequestGetEncapsulatedResponse, /*wValue=*/0, device_->Candidate().control_interface, @@ -595,9 +626,10 @@ Result> UsbControlChannel::ReceiveMessage() { Error::FromLibUsb(transferred, Tr(Msg::kUsbGetEncapsulatedFailed))); } - // 规范:设备尚无有效响应时返回 **1 字节 0x00**,而不是 STALL。 - // 因此不足一个 RNDIS 消息头(8 字节)的结果一律当作「还没准备好」, - // 返回空视图让调用方重试 —— 这**不是**错误。 + // Spec: while the device has no valid response yet it returns **a single + // 0x00 byte** instead of STALL. So anything shorter than one RNDIS message + // header (8 bytes) is treated as "not ready yet" and an empty view is + // returned for the caller to retry -- this is **not** an error. if (static_cast(transferred) < rndis::kMessageHeaderBytes) { return std::span{}; } @@ -610,8 +642,9 @@ rndis::NotificationResult UsbControlChannel::WaitForNotification(std::uint32_t t return rndis::NotificationResult::kNotSupported; } - // 只查(并消费)由事件线程置位的原子标志。**绝不进入 libusb 的等待路径** —— - // 那正是控制线程卡死的成因(详见 device.h 的说明)。 + // Only test (and consume) the atomic flag set by the event thread. + // **Never enter libusb's wait path** -- that is exactly what wedges the + // control thread (see the notes in device.h). if (notification_pending_.exchange(false, std::memory_order_acq_rel)) { return rndis::NotificationResult::kResponseAvailable; } @@ -619,8 +652,9 @@ rndis::NotificationResult UsbControlChannel::WaitForNotification(std::uint32_t t return rndis::NotificationResult::kTimeout; } - // 需要等一会儿时,用自己的小步睡眠轮询这个标志,而不是让 libusb 去等。 - // 这样最坏情况只是多等 1 ms,绝不会永久阻塞。 + // When we do need to wait a while, poll this flag ourselves with short + // sleeps instead of letting libusb wait. The worst case is then one extra + // millisecond, never a permanent block. for (std::uint32_t waited = 0; waited < timeout_millis; ++waited) { std::this_thread::sleep_for(std::chrono::milliseconds(1)); if (notification_pending_.exchange(false, std::memory_order_acq_rel)) { diff --git a/src/version.cc.in b/src/version.cc.in index 0324e20..8b70a99 100644 --- a/src/version.cc.in +++ b/src/version.cc.in @@ -1,4 +1,4 @@ -// 由 CMake 从 src/version.cc.in 生成,请勿手工编辑。 +// Generated by CMake from src/version.cc.in; do not edit by hand. #include "tetherkit/version.h" namespace tetherkit { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5a218ff..06a1d52 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,9 +1,11 @@ -# tests —— 单元测试 +# tests -- unit tests # -# 全部测试链接进单个可执行文件 tetherkit_tests,理由: -# * doctest 的注册是全局的,单一二进制启动开销最低; -# * 通过 doctest 的 --test-suite 过滤即可按模块运行; -# * CTest 侧按 test-suite 注册多个用例,失败定位仍然精确。 +# Every test is linked into a single executable, tetherkit_tests, because: +# * doctest registration is global, and one binary has the lowest startup +# overhead; +# * the module can be run via doctest's --test-suite filter alone; +# * CTest registers several cases per test-suite, so failing cases still +# pinpoint exactly which module broke. add_executable( tetherkit_tests @@ -22,34 +24,41 @@ add_executable( test_core_bridge.cc test_capi.cc) -# tk_rndis / tk_net 都 PUBLIC 链接了 tk_common,无需重复列出(否则链接器报 -# "ignoring duplicate libraries")。 -# tk_core PUBLIC 链接了 tk_common/tk_rndis/tk_net/tk_usb,列它一个就够。 -# tk_capi PUBLIC 链接了 tk_version 与 tk_core,因此这两个都不用重复列出。 +# tk_rndis / tk_net both PUBLIC-link tk_common, so it does not need repeating +# (otherwise the linker prints "ignoring duplicate libraries"). +# tk_core PUBLIC-links tk_common/tk_rndis/tk_net/tk_usb, so listing it alone is +# enough. +# tk_capi PUBLIC-links tk_version and tk_core, so neither needs repeating. target_link_libraries(tetherkit_tests PRIVATE tetherkit_base doctest tk_capi) -# src/capi 目录:capi_support.h 是实现内部头,不对外安装,但要能被测试直接覆盖。 +# The src/capi directory: capi_support.h is an implementation-internal header, +# not installed publicly, but it must be includable directly by the tests. target_include_directories(tetherkit_tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}" "${PROJECT_SOURCE_DIR}/src/capi") # ------------------------------------------------------------------------------ -# CTest 注册 +# CTest registration # -# ⚠️ 这里踩过一个「整套测试静默失效却全绿」的坑,务必注意两点: +# This repo once had a pitfall where the whole suite ran zero cases and read +# green. Two things to watch: # -# 1. **过滤参数不能加引号。** 写成 `--test-suite="${_suite}"` 时,CMake 会把 -# 引号当作参数内容原样传下去(实测 ctest -V 显示传的是 -# `"--test-suite="rndis.state_machine""`),doctest 匹配不到任何用例 —— -# 于是「跑了 0 个用例」,而 doctest 对零匹配返回退出码 0,ctest 就报 -# 「Passed」。真实的失败被完全吞掉。 +# 1. **The filter argument must not be quoted.** Written as +# `--test-suite="${_suite}"`, CMake passes the quote marks through as +# argument content (verified with ctest -V: it passes +# `"--test-suite="rndis.state_machine""`), doctest matches zero cases -- +# so "0 cases ran", and doctest returns exit code 0 on zero matches, so +# ctest reports "Passed". The real failures were swallowed entirely. # -# 2. 因此再加一道保险:给每个用例设 **FAIL_REGULAR_EXPRESSION**,只要输出里 -# 出现「assertions: 0」就判失败。将来谁再打错 suite 名字会立刻红掉。 -# 这里**必须**用 FAIL_ 而不是 PASS_REGULAR_EXPRESSION —— 后者会 -# **取代**退出码判定(CMake 的文档行为),一设上去真实的断言失败反而被 -# 忽略掉,等于把刚修好的坑又挖回来。FAIL_ 是叠加在退出码之上的。 +# 2. Hence an extra safety net: each case sets **FAIL_REGULAR_EXPRESSION** so +# that any "assertions: 0" in the output fails the test. A mistyped suite +# name will go red immediately from now on. +# This **must** be FAIL_ rather than PASS_REGULAR_EXPRESSION -- the latter +# **replaces** the exit-code check (documented CMake behavior), so once set, +# a genuine assertion failure would be ignored instead, digging the pit +# right back open. FAIL_ is additive on top of the exit code. # -# 另外单独注册一个 `all` 用例跑整个二进制作为**权威结果** —— 即使某个 suite -# 忘了加进下面的列表,它也会把失败暴露出来。 +# A separate `all` case that runs the entire binary is registered as the +# **authoritative result** -- even if a suite gets forgotten in the list below, +# it will expose the failure. # ------------------------------------------------------------------------------ add_test(NAME all COMMAND tetherkit_tests) @@ -78,7 +87,7 @@ set(TETHERKIT_TEST_SUITES capi.net_config) foreach(_suite IN LISTS TETHERKIT_TEST_SUITES) - # 注意:--test-suite= 后面**不要**加引号,见上方说明。 + # Note: do **not** put quotes after --test-suite=, see the explanation above. add_test(NAME "${_suite}" COMMAND tetherkit_tests --test-suite=${_suite}) set_tests_properties("${_suite}" PROPERTIES FAIL_REGULAR_EXPRESSION "assertions: +0 \\|") endforeach() diff --git a/tests/mock_control_channel.h b/tests/mock_control_channel.h index 8c23b0e..b8499e6 100644 --- a/tests/mock_control_channel.h +++ b/tests/mock_control_channel.h @@ -1,8 +1,9 @@ -// 内存版 RNDIS 控制通道,用于离线驱动状态机。 +// In-memory RNDIS control channel, for driving the state machine offline. // -// 它扮演「设备」这一侧:收下主机发来的请求,按脚本回复响应,还能主动插入 -// INDICATE_STATUS 与设备发起的 KEEPALIVE —— 这些正是真机上极难复现、 -// 却最容易写错的路径。 +// It plays the "device" side: accepts requests from the host, replies with +// scripted responses, and can also inject INDICATE_STATUS and device-initiated +// KEEPALIVE -- exactly the paths that are nearly impossible to reproduce on a +// real device yet the most error-prone ones. #pragma once #include @@ -23,25 +24,25 @@ namespace tetherkit::testing { -/// 模拟设备行为的控制通道。 +/// Control channel that emulates device behavior. class MockControlChannel final : public rndis::ControlChannel { public: MockControlChannel() = default; // --------------------------------------------------------------------------- - // ControlChannel 实现 + // ControlChannel implementation // --------------------------------------------------------------------------- [[nodiscard]] Status SendMessage(std::span message) override { if (send_failure_countdown_ > 0) { --send_failure_countdown_; if (send_failure_countdown_ == 0) { - return std::unexpected(Error::Generic("mock:按测试设置让发送失败")); + return std::unexpected(Error::Generic("mock: send fails per the test's setup")); } } sent_messages_.emplace_back(message.begin(), message.end()); - // 让「设备」根据收到的请求决定怎么回复。 + // Let the "device" decide how to reply based on the request received. if (request_handler_) { request_handler_(message, *this); } @@ -53,11 +54,12 @@ class MockControlChannel final : public rndis::ControlChannel { if (receive_failure_countdown_ > 0) { --receive_failure_countdown_; if (receive_failure_countdown_ == 0) { - return std::unexpected(Error::Generic("mock:按测试设置让接收失败")); + return std::unexpected(Error::Generic("mock: receive fails per the test's setup")); } } if (pending_responses_.empty()) { - // 与真实设备一致:没有响应时返回空(真机上是 1 字节 0x00)。 + // Matches a real device: return empty when there is no response (1 byte + // of 0x00 on real hardware). return std::span{}; } current_response_ = std::move(pending_responses_.front()); @@ -68,8 +70,9 @@ class MockControlChannel final : public rndis::ControlChannel { [[nodiscard]] rndis::NotificationResult WaitForNotification( std::uint32_t timeout_millis) override { ++notification_call_count_; - // 记录下来供回归测试断言:**任何一次传 0 都是 bug** —— - // libusb 在 darwin 上 timeout=0 表示无限等待,会把控制线程永久卡死。 + // Recorded for regression tests to assert: **any call with 0 is a bug** -- + // on libusb darwin, timeout=0 means infinite wait and would wedge the + // control thread permanently. notification_timeouts_.push_back(timeout_millis); if (!has_interrupt_endpoint_) { return rndis::NotificationResult::kNotSupported; @@ -83,42 +86,43 @@ class MockControlChannel final : public rndis::ControlChannel { [[nodiscard]] std::string_view Describe() const noexcept override { return description_; } // --------------------------------------------------------------------------- - // 测试注入 + // Test injection // --------------------------------------------------------------------------- - /// 设置「设备」的请求处理逻辑。 + /// Sets the "device's" request-handling logic. using RequestHandler = std::function request, MockControlChannel& channel)>; void SetRequestHandler(RequestHandler handler) { request_handler_ = std::move(handler); } - /// 排入一条设备 → 主机的消息。 + /// Queues one device -> host message. void EnqueueResponse(std::vector message) { pending_responses_.push_back(std::move(message)); } - /// 模拟设备没有中断端点(Linux 的 host 驱动就完全忽略它)。 + /// Emulates a device with no interrupt endpoint (the Linux host driver + /// simply ignores it entirely). void SetHasInterruptEndpoint(bool has) noexcept { has_interrupt_endpoint_ = has; } - /// 让第 N 次 SendMessage 失败(N 从 1 计)。 + /// Makes the Nth SendMessage fail (N counted from 1). void FailSendOnCall(std::uint32_t call_index) noexcept { send_failure_countdown_ = call_index; } - /// 让第 N 次 ReceiveMessage 失败。 + /// Makes the Nth ReceiveMessage fail. void FailReceiveOnCall(std::uint32_t call_index) noexcept { receive_failure_countdown_ = call_index; } // --------------------------------------------------------------------------- - // 观测 + // Observers // --------------------------------------------------------------------------- [[nodiscard]] const std::vector>& SentMessages() const noexcept { return sent_messages_; } - /// 主机发出的第 index 条消息的类型码。 + /// Message type code of the index-th message sent by the host. [[nodiscard]] std::uint32_t SentMessageType(std::size_t index) const { if (index >= sent_messages_.size() || sent_messages_[index].size() < rndis::kMessageHeaderBytes) { @@ -127,7 +131,7 @@ class MockControlChannel final : public rndis::ControlChannel { return LoadLe32(sent_messages_[index].data() + rndis::kMessageTypeOffset); } - /// 统计主机一共发了多少条指定类型的消息。 + /// Counts how many messages of a given type the host sent in total. [[nodiscard]] std::size_t CountSent(rndis::MessageType type) const { std::size_t count = 0; for (std::size_t i = 0; i < sent_messages_.size(); ++i) { @@ -138,7 +142,8 @@ class MockControlChannel final : public rndis::ControlChannel { return count; } - /// 找出主机发的第一条 SET 消息,其 OID 等于给定值;返回它的 LE32 负载。 + /// Finds the host's first SET message whose OID equals the given value; + /// returns its LE32 payload. [[nodiscard]] bool FindSetUint32(rndis::Oid oid, std::uint32_t& out_value, std::size_t skip = 0) const { for (const std::vector& message : sent_messages_) { @@ -164,7 +169,8 @@ class MockControlChannel final : public rndis::ControlChannel { [[nodiscard]] std::uint32_t ReceiveCallCount() const noexcept { return receive_call_count_; } - /// 历次 WaitForNotification 收到的超时值。用于断言从不传 0。 + /// Timeout values passed to every WaitForNotification so far. Used to + /// assert 0 is never passed. [[nodiscard]] const std::vector& NotificationTimeouts() const noexcept { return notification_timeouts_; } @@ -176,7 +182,7 @@ class MockControlChannel final : public rndis::ControlChannel { void ClearSentMessages() { sent_messages_.clear(); } private: - std::string description_ = "mock 设备"; + std::string description_ = "mock device"; RequestHandler request_handler_; std::vector> sent_messages_; @@ -192,10 +198,10 @@ class MockControlChannel final : public rndis::ControlChannel { }; // ============================================================================= -// 构造设备回复的便捷函数 +// Convenience helpers for building device responses // ============================================================================= -/// 造一条 INITIALIZE_CMPLT。 +/// Builds an INITIALIZE_CMPLT. [[nodiscard]] inline std::vector MakeInitializeComplete( std::uint32_t request_id, std::uint32_t max_transfer_size = 2048, std::uint32_t max_packets = 1, std::uint32_t alignment_factor = 0, @@ -219,7 +225,7 @@ class MockControlChannel final : public rndis::ControlChannel { return message; } -/// 造一条 QUERY_CMPLT,负载是任意字节。 +/// Builds a QUERY_CMPLT whose payload is arbitrary bytes. [[nodiscard]] inline std::vector MakeQueryComplete( std::uint32_t request_id, std::span payload, std::uint32_t status = 0) { const auto payload_length = static_cast(payload.size()); @@ -239,7 +245,7 @@ class MockControlChannel final : public rndis::ControlChannel { return message; } -/// 造一条 QUERY_CMPLT,负载是一个 LE32。 +/// Builds a QUERY_CMPLT whose payload is a single LE32. [[nodiscard]] inline std::vector MakeQueryCompleteUint32(std::uint32_t request_id, std::uint32_t value) { std::array payload{}; @@ -247,7 +253,7 @@ class MockControlChannel final : public rndis::ControlChannel { return MakeQueryComplete(request_id, payload); } -/// 造一条 QUERY_CMPLT,负载是 6 字节 MAC。 +/// Builds a QUERY_CMPLT whose payload is a 6-byte MAC. [[nodiscard]] inline std::vector MakeQueryCompleteMac(std::uint32_t request_id, const rndis::MacAddress& mac) { std::array payload{}; @@ -255,7 +261,7 @@ class MockControlChannel final : public rndis::ControlChannel { return MakeQueryComplete(request_id, payload); } -/// 造一条 SET_CMPLT。 +/// Builds a SET_CMPLT. [[nodiscard]] inline std::vector MakeSetComplete(std::uint32_t request_id, std::uint32_t status = 0) { std::vector message(rndis::kSetCmpltBytes); @@ -267,7 +273,7 @@ class MockControlChannel final : public rndis::ControlChannel { return message; } -/// 造一条 KEEPALIVE_CMPLT。 +/// Builds a KEEPALIVE_CMPLT. [[nodiscard]] inline std::vector MakeKeepAliveComplete(std::uint32_t request_id, std::uint32_t status = 0) { std::vector message(rndis::kKeepAliveCmpltBytes); @@ -280,7 +286,8 @@ class MockControlChannel final : public rndis::ControlChannel { return message; } -/// 造一条**设备主动发起**的 KEEPALIVE_MSG(主机必须回 CMPLT)。 +/// Builds a KEEPALIVE_MSG **initiated by the device** (the host must reply +/// with a CMPLT). [[nodiscard]] inline std::vector MakeDeviceKeepAlive(std::uint32_t request_id) { std::vector message(rndis::kKeepAliveMsgBytes); std::byte* base = message.data(); @@ -290,7 +297,7 @@ class MockControlChannel final : public rndis::ControlChannel { return message; } -/// 造一条 RESET_CMPLT。注意它**没有 RequestId**,Status 在 offset 8。 +/// Builds a RESET_CMPLT. Note it has **no RequestId**; Status is at offset 8. [[nodiscard]] inline std::vector MakeResetComplete(bool addressing_reset, std::uint32_t status = 0) { std::vector message(rndis::kResetCmpltBytes); @@ -302,7 +309,7 @@ class MockControlChannel final : public rndis::ControlChannel { return message; } -/// 造一条 INDICATE_STATUS(无负载)。 +/// Builds an INDICATE_STATUS (no payload). [[nodiscard]] inline std::vector MakeIndicateStatus(rndis::StatusCode status) { std::vector message(rndis::kIndicateStatusHeaderBytes); std::byte* base = message.data(); @@ -314,7 +321,8 @@ class MockControlChannel final : public rndis::ControlChannel { return message; } -/// 从主机发来的请求里取 RequestId(QUERY/SET/INITIALIZE/KEEPALIVE 都在 offset 8)。 +/// Extracts the RequestId from a host request (QUERY/SET/INITIALIZE/KEEPALIVE +/// all carry it at offset 8). [[nodiscard]] inline std::uint32_t RequestIdOf(std::span request) { if (request.size() < 12) { return 0; @@ -322,7 +330,7 @@ class MockControlChannel final : public rndis::ControlChannel { return LoadLe32(request.data() + 8); } -/// 从主机发来的 QUERY/SET 请求里取 OID。 +/// Extracts the OID from a host QUERY/SET request. [[nodiscard]] inline std::uint32_t OidOf(std::span request) { if (request.size() < rndis::kQuerySetHeaderBytes) { return 0; @@ -330,9 +338,10 @@ class MockControlChannel final : public rndis::ControlChannel { return LoadLe32(request.data() + rndis::kQuerySetOidOffset); } -/// 一个「行为正常的 Android 设备」的请求处理逻辑。 +/// Request-handling logic of a "well-behaved Android device". /// -/// 覆盖启动序列需要的全部 OID,未知的 OID 一律回 NOT_SUPPORTED(真机也是这样)。 +/// Covers every OID the startup sequence needs; unknown OIDs are answered with +/// NOT_SUPPORTED (real devices behave the same). [[nodiscard]] inline MockControlChannel::RequestHandler MakeWellBehavedDevice( const rndis::MacAddress& mac, std::uint32_t max_transfer_size = 2048, std::uint32_t max_packets = 1, std::uint32_t alignment_factor = 0) { @@ -357,16 +366,17 @@ class MockControlChannel final : public rndis::ControlChannel { } else if (oid == rndis::ToRaw(rndis::Oid::kGenMaximumFrameSize)) { channel.EnqueueResponse(MakeQueryCompleteUint32(request_id, 1500)); } else if (oid == rndis::ToRaw(rndis::Oid::kGenLinkSpeed)) { - // 单位是 100 bps:4800000 → 480 Mbps + // Unit is 100 bps: 4800000 -> 480 Mbps channel.EnqueueResponse(MakeQueryCompleteUint32(request_id, 4'800'000)); } else if (oid == rndis::ToRaw(rndis::Oid::kGenMediaConnectStatus)) { - // 0 = 已连接 + // 0 = connected channel.EnqueueResponse(MakeQueryCompleteUint32( request_id, static_cast(rndis::MediaState::kConnected))); } else if (oid == rndis::ToRaw(rndis::Oid::kGenVendorId)) { channel.EnqueueResponse(MakeQueryCompleteUint32(request_id, 0x0018D1)); } else { - // 未知 / 可选 OID:回不支持。主线 Linux gadget 也是这样。 + // Unknown / optional OIDs: answer not supported. The mainline Linux + // gadget does the same. channel.EnqueueResponse(MakeQueryComplete(request_id, {}, rndis::ToRaw(rndis::StatusCode::kNotSupported))); } @@ -384,11 +394,11 @@ class MockControlChannel final : public rndis::ControlChannel { channel.EnqueueResponse(MakeResetComplete(/*addressing_reset=*/true)); return; } - // HALT_MSG 设备不回复。 + // The device does not reply to HALT_MSG. }; } -/// 记录状态机事件的观察者。 +/// Observer that records state machine events. class RecordingObserver final : public rndis::StateMachineObserver { public: struct Transition { diff --git a/tests/mock_data_channel.h b/tests/mock_data_channel.h index c31ab10..34af2bc 100644 --- a/tests/mock_data_channel.h +++ b/tests/mock_data_channel.h @@ -1,12 +1,15 @@ -// 内存版 RNDIS 数据通道,用于离线驱动桥接层。 +// In-memory RNDIS data channel, for driving the bridge layer offline. // -// 它扮演「USB 设备」这一侧: -// * StartReceiving 之后可以用 InjectFromDevice 注入「设备发来的帧」,由一个 -// 内部生产者线程推进桥接层的 RX 队列 —— 这模拟 libusb 事件线程的角色; -// * SendFrames 收下「主机要发给设备的帧」,供测试断言。 +// It plays the "USB device" side: +// * After StartReceiving, InjectFromDevice can inject "frames from the +// device"; an internal producer thread pushes them into the bridge's RX +// ring -- this simulates the libusb event-thread role. +// * SendFrames accepts "frames the host wants to send to the device", for +// test assertions. // -// 这样桥接层的线程模型、批处理、背压与统计就能在没有 USB 设备的机器上被完整 -// 测试,包括 TSan 下的并发正确性。 +// This lets the bridge's thread model, batching, backpressure, and statistics +// be fully tested on a machine without any USB device, including concurrency +// correctness under TSan. #pragma once #include @@ -23,7 +26,7 @@ namespace tetherkit::testing { -/// 模拟设备行为的数据通道。 +/// Data channel that emulates device behavior. class MockDataChannel final : public usb::DataChannel { public: explicit MockDataChannel(std::uint32_t max_transfer_bytes = 16 * 1024) @@ -37,7 +40,7 @@ class MockDataChannel final : public usb::DataChannel { ~MockDataChannel() override { Shutdown(); } // --------------------------------------------------------------------------- - // DataChannel 实现 + // DataChannel implementation // --------------------------------------------------------------------------- [[nodiscard]] Status StartReceiving(FrameRing& rx_ring, @@ -50,10 +53,11 @@ class MockDataChannel final : public usb::DataChannel { [[nodiscard]] Result SendFrames(std::span frames) override { if (send_should_fail_.load(std::memory_order_acquire)) { - return std::unexpected(Error::Generic("mock:按测试设置让发送失败")); + return std::unexpected(Error::Generic("mock: send fails per the test's setup")); } - // 模拟传输池容量有限:一次最多吃下 accept_limit_ 帧,超出即背压。 + // Emulates the transfer pool's limited capacity: accepts at most + // accept_limit_ frames per call; beyond that, backpressure. const std::uint32_t limit = accept_limit_.load(std::memory_order_acquire); const auto accepted = static_cast(std::min(frames.size(), limit)); @@ -84,7 +88,8 @@ class MockDataChannel final : public usb::DataChannel { void Shutdown() override { receiving_.store(false, std::memory_order_release); shutdown_called_.store(true, std::memory_order_release); - // 唤醒可能在 WaitForSendCapacity 里等容量的线程(与真实实现同款空临界区惯用法)。 + // Wake any thread waiting for capacity inside WaitForSendCapacity (the + // same empty-critical-section idiom as the real implementation). { const std::lock_guard guard(wait_mutex_); } wait_cv_.notify_all(); } @@ -101,19 +106,22 @@ class MockDataChannel final : public usb::DataChannel { return async_send_errors_.load(std::memory_order_relaxed); } - /// 模拟异步完成回调里发生的错误(真实实现里来自 STALL / 传输失败)。 + /// Emulates errors raised inside the async completion callback (in the real + /// implementation these come from STALL / transfer failure). void InjectAsyncSendError() noexcept { async_send_errors_.fetch_add(1, std::memory_order_relaxed); } // --------------------------------------------------------------------------- - // 测试注入 + // Test injection // --------------------------------------------------------------------------- - /// 直接把一批「设备发来的帧」推进桥接层的 RX 队列。 + /// Pushes a batch of "frames from the device" directly into the bridge's RX + /// ring. /// - /// 走 BatchWrite,与真实的 libusb 回调路径一致。返回实际入队的帧数 - /// (队列满时会少于请求数,这正是要测的丢包路径)。 + /// Uses BatchWrite, matching the real libusb callback path. Returns the + /// number of frames actually enqueued (fewer than requested when the queue + /// is full -- exactly the frame-drop path under test). [[nodiscard]] std::uint32_t InjectFromDevice( const std::vector>& frames) { if (rx_ring_ == nullptr) { @@ -138,10 +146,12 @@ class MockDataChannel final : public usb::DataChannel { return accepted; } - /// 设置一次 SendFrames 最多吃下多少帧。设 0 可模拟传输池完全占满(背压)。 + /// Sets how many frames one SendFrames call may accept at most. Setting 0 + /// emulates a fully exhausted transfer pool (backpressure). /// - /// 从 0 恢复为非 0 相当于「传输完成、槽位归还」,会唤醒等在 - /// WaitForSendCapacity 里的桥接层 TX 线程。 + /// Recovering from 0 to a non-zero value is equivalent to "transfer + /// completed, slot returned", and wakes the bridge's TX thread waiting in + /// WaitForSendCapacity. void SetAcceptLimit(std::uint32_t limit) { accept_limit_.store(limit, std::memory_order_release); { const std::lock_guard guard(wait_mutex_); } @@ -153,7 +163,7 @@ class MockDataChannel final : public usb::DataChannel { } // --------------------------------------------------------------------------- - // 观测 + // Observers // --------------------------------------------------------------------------- [[nodiscard]] std::uint64_t SentFrameCount() const noexcept { @@ -180,7 +190,8 @@ class MockDataChannel final : public usb::DataChannel { return receiving_.load(std::memory_order_acquire); } - /// WaitForSendCapacity 被调用的次数 —— 断言「桥接层确实在等而不是在丢」。 + /// Number of times WaitForSendCapacity was called -- asserts that "the + /// bridge actually waits instead of dropping". [[nodiscard]] std::uint64_t WaitCalls() const noexcept { return wait_calls_.load(std::memory_order_relaxed); } diff --git a/tests/test_capi.cc b/tests/test_capi.cc index b54f1b8..023b2c6 100644 --- a/tests/test_capi.cc +++ b/tests/test_capi.cc @@ -1,9 +1,11 @@ -// C ABI 层的单元测试。 +// Unit tests for the C ABI layer. // -// 覆盖那些「写错了不会当场崩、但会在 GUI 上表现成乱码或安全漏洞」的地方: -// UTF-8 边界截断、网卡名校验、日志环形缓冲的丢弃语义。 +// Covers cases that "do not crash immediately when wrong, but show up as garbled text or +// security holes in the GUI": UTF-8 boundary truncation, interface-name validation, and +// the log ring buffer's drop semantics. // -// 会话与网卡配置需要 root 与真实设备,不在这里测 —— 只测它们的参数校验分支。 +// Session and network configuration require root and a real device, so they are not tested +// here -- only their argument-validation branches. #include #include #include @@ -19,63 +21,64 @@ using tetherkit::capi::CopyText; using tetherkit::capi::IsValidFethName; TEST_SUITE("capi.support") { + TEST_CASE("CopyText copies normally and adds a terminator") { + std::array buffer{}; + CopyText(buffer.data(), buffer.size(), "feth0"); + CHECK(std::string{buffer.data()} == "feth0"); + } -TEST_CASE("CopyText 正常拷贝并补终止符") { - std::array buffer{}; - CopyText(buffer.data(), buffer.size(), "feth0"); - CHECK(std::string{buffer.data()} == "feth0"); -} - -TEST_CASE("CopyText 容量为 0 或目标为空时不写内存") { - CopyText(nullptr, 16, "x"); // 不崩即通过 - std::array buffer{'a', 'b', 'c', 'd'}; - CopyText(buffer.data(), 0, "xyz"); - CHECK(buffer[0] == 'a'); -} + TEST_CASE("CopyText writes nothing when the capacity is 0 or the destination is null") { + CopyText(nullptr, 16, "x"); // No crash means it passed. + std::array buffer{'a', 'b', 'c', 'd'}; + CopyText(buffer.data(), 0, "xyz"); + CHECK(buffer[0] == 'a'); + } -TEST_CASE("CopyText 截断落在 UTF-8 字符边界上") { - // 「设备」= 6 字节(每字 3 字节)。给 6 字节容量(可用 5 字节 + 终止符)时, - // 只能装下第一个字;若按字节硬切会留下半个「备」,Swift 侧整串会变成替换字符。 - std::array buffer{}; - CopyText(buffer.data(), buffer.size(), "设备"); - CHECK(std::string{buffer.data()} == "设"); - CHECK(std::strlen(buffer.data()) == 3); -} + TEST_CASE("CopyText truncates on a UTF-8 character boundary") { + // "€€" = 6 bytes (3 bytes per character). With a 6-byte capacity (5 usable bytes + + // terminator), only the first character fits; a hard byte cut would leave half of the + // second "€", and the whole string would become a replacement character on the Swift side. + std::array buffer{}; + CopyText(buffer.data(), buffer.size(), "€€"); + CHECK(std::string{buffer.data()} == "€"); + CHECK(std::strlen(buffer.data()) == 3); + } -TEST_CASE("CopyText 恰好装得下时不截断") { - std::array buffer{}; - CopyText(buffer.data(), buffer.size(), "设备"); - CHECK(std::string{buffer.data()} == "设备"); -} + TEST_CASE("CopyText does not truncate when it fits exactly") { + std::array buffer{}; + CopyText(buffer.data(), buffer.size(), "€€"); + CHECK(std::string{buffer.data()} == "€€"); + } -TEST_CASE("CopyText 首字符就装不下时得到空串而非半个字符") { - std::array buffer{}; // 可用 2 字节,装不下 3 字节的「设」 - CopyText(buffer.data(), buffer.size(), "设备"); - CHECK(std::string{buffer.data()}.empty()); -} + TEST_CASE( + "CopyText yields an empty string rather than half a character when the first does not fit") { + std::array buffer{}; // 2 usable bytes, not enough for the 3-byte "€" + CopyText(buffer.data(), buffer.size(), "€€"); + CHECK(std::string{buffer.data()}.empty()); + } -TEST_CASE("IsValidFethName 只接受 feth+数字") { - CHECK(IsValidFethName("feth0")); - CHECK(IsValidFethName("feth12")); + TEST_CASE("IsValidFethName accepts only feth plus digits") { + CHECK(IsValidFethName("feth0")); + CHECK(IsValidFethName("feth12")); - SUBCASE("拒绝真实物理网卡 —— 防止误把用户的 Wi-Fi 配置冲掉") { - CHECK_FALSE(IsValidFethName("en0")); - CHECK_FALSE(IsValidFethName("bridge0")); - } - SUBCASE("拒绝缺少编号与非数字后缀") { - CHECK_FALSE(IsValidFethName("feth")); - CHECK_FALSE(IsValidFethName("fethX")); - CHECK_FALSE(IsValidFethName("feth0a")); - } - SUBCASE("拒绝壳元字符与路径分隔符") { - CHECK_FALSE(IsValidFethName("feth0; rm -rf /")); - CHECK_FALSE(IsValidFethName("../feth0")); - CHECK_FALSE(IsValidFethName("")); - } - SUBCASE("拒绝超过 IFNAMSIZ 的名字") { - CHECK_FALSE(IsValidFethName("feth123456789012345")); + SUBCASE("rejects real physical interfaces to avoid overwriting the user's Wi-Fi config") { + CHECK_FALSE(IsValidFethName("en0")); + CHECK_FALSE(IsValidFethName("bridge0")); + } + SUBCASE("rejects a missing index and non-digit suffixes") { + CHECK_FALSE(IsValidFethName("feth")); + CHECK_FALSE(IsValidFethName("fethX")); + CHECK_FALSE(IsValidFethName("feth0a")); + } + SUBCASE("rejects shell metacharacters and path separators") { + CHECK_FALSE(IsValidFethName("feth0; rm -rf /")); + CHECK_FALSE(IsValidFethName("../feth0")); + CHECK_FALSE(IsValidFethName("")); + } + SUBCASE("rejects names longer than IFNAMSIZ") { + CHECK_FALSE(IsValidFethName("feth123456789012345")); + } } -} namespace { @@ -83,7 +86,8 @@ using tetherkit::capi::DeviceIdentity; using tetherkit::capi::ReconcileDeviceStrings; using tetherkit::capi::RememberedDeviceStrings; -/// 造一条已填好身份与字符串的枚举结果。字符串传空串表示「这次没读到」。 +/// Build an enumeration result with identity and strings filled in. Pass an empty string +/// to mean "not read this time". tk_device_info_t DeviceInfo(const DeviceIdentity& identity, const char* manufacturer, const char* product, const char* serial) { tk_device_info_t info{}; @@ -99,27 +103,27 @@ tk_device_info_t DeviceInfo(const DeviceIdentity& identity, const char* manufact } // namespace -TEST_CASE("ReconcileDeviceStrings 在读不到时回填上次成功读到的名字") { - // 场景即 GUI 上那个真实缺陷:连接后设备被独占、读不到字符串, - // 「vivo iQOO Z10x」退化成「USB 设备 2d95:600b」。 +TEST_CASE("ReconcileDeviceStrings backfills the last successfully read name when unreadable") { + // The scenario is that real GUI defect: after connecting, the device is claimed exclusively + // and its strings cannot be read, so "vivo iQOO Z10x" degrades to "USB device 2d95:600b". const DeviceIdentity phone{.bus_number = 0, .device_address = 1, .vendor_id = 0x2d95, .product_id = 0x600b}; std::vector memory; const std::vector present{phone}; - // 第一次:空闲时读到了 → 记住。 + // First time: read while idle -> remember it. std::vector infos{DeviceInfo(phone, "vivo", "iQOO Z10x", "10AFAC2X72005KT")}; ReconcileDeviceStrings(memory, present, infos); REQUIRE(memory.size() == 1); - // 第二次:会话启动,读被跳过(全空)→ 回填。 + // Second time: session starts, reads are skipped (all empty) -> backfill. infos = {DeviceInfo(phone, "", "", "")}; ReconcileDeviceStrings(memory, present, infos); CHECK(std::string{infos[0].manufacturer} == "vivo"); CHECK(std::string{infos[0].product} == "iQOO Z10x"); CHECK(std::string{infos[0].serial} == "10AFAC2X72005KT"); - SUBCASE("再次读到新值时覆盖记忆") { + SUBCASE("overwrites the memory when a new value is read again") { infos = {DeviceInfo(phone, "vivo", "iQOO Z10x", "NEWSERIAL")}; ReconcileDeviceStrings(memory, present, infos); infos = {DeviceInfo(phone, "", "", "")}; @@ -128,7 +132,8 @@ TEST_CASE("ReconcileDeviceStrings 在读不到时回填上次成功读到的名 } } -TEST_CASE("ReconcileDeviceStrings 不把旧名字安给接替同一地址的另一台设备") { +TEST_CASE( + "ReconcileDeviceStrings does not give the old name to another device at the same address") { const DeviceIdentity old_phone{.bus_number = 0, .device_address = 1, .vendor_id = 0x2d95, .product_id = 0x600b}; std::vector memory; @@ -136,7 +141,7 @@ TEST_CASE("ReconcileDeviceStrings 不把旧名字安给接替同一地址的另 ReconcileDeviceStrings(memory, {std::vector{old_phone}}, infos); REQUIRE(memory.size() == 1); - SUBCASE("同地址但 VID:PID 不同 → 身份不同,不回填") { + SUBCASE("same address but different VID:PID -> different identity, no backfill") { const DeviceIdentity other{.bus_number = 0, .device_address = 1, .vendor_id = 0x18d1, .product_id = 0x4ee4}; infos = {DeviceInfo(other, "", "", "")}; @@ -144,33 +149,36 @@ TEST_CASE("ReconcileDeviceStrings 不把旧名字安给接替同一地址的另 CHECK(std::string{infos[0].product}.empty()); } - SUBCASE("设备拔掉(不在场)→ 记忆清除;重插后读不到也不回填") { - // 拔掉:present 为空。 + SUBCASE( + "device unplugged (not present) -> memory cleared; no backfill if unreadable after replug") { + // Unplugged: present is empty. infos.clear(); ReconcileDeviceStrings(memory, {}, infos); CHECK(memory.empty()); - // 重插同身份(可能已是同型号的另一台),读不到 → 宁可显示 VID:PID。 + // Replugged with the same identity (possibly another device of the same model), + // unreadable -> better to show VID:PID. infos = {DeviceInfo(old_phone, "", "", "")}; ReconcileDeviceStrings(memory, {std::vector{old_phone}}, infos); CHECK(std::string{infos[0].product}.empty()); } } -TEST_CASE("ReconcileDeviceStrings 对没有字符串的设备保持诚实的空串") { +TEST_CASE("ReconcileDeviceStrings keeps an honest empty string for a device with no strings") { const DeviceIdentity mute{.bus_number = 2, .device_address = 7, .vendor_id = 0x1234, .product_id = 0x5678}; std::vector memory; std::vector infos{DeviceInfo(mute, "", "", "")}; ReconcileDeviceStrings(memory, {std::vector{mute}}, infos); - // 不记忆(没读到东西)、不回填(无中生有)。 + // Do not remember (nothing was read) and do not backfill (nothing from nowhere). CHECK(memory.empty()); CHECK(std::string{infos[0].product}.empty()); } -TEST_CASE("ReconcileDeviceStrings 不清除仍在场但没被填出的设备的记忆") { - // 调用方数组容量不够时,present 比 infos 长 —— 超出部分只是没被填, - // 不是拔掉了。 +TEST_CASE( + "ReconcileDeviceStrings does not clear memory for a present device that was not filled in") { + // When the caller's array capacity is insufficient, present is longer than infos -- the + // extra entries were simply not filled in, not unplugged. const DeviceIdentity first{.bus_number = 0, .device_address = 1, .vendor_id = 0x2d95, .product_id = 0x600b}; const DeviceIdentity second{.bus_number = 0, .device_address = 2, @@ -183,12 +191,13 @@ TEST_CASE("ReconcileDeviceStrings 不清除仍在场但没被填出的设备的 ReconcileDeviceStrings(memory, both, infos); REQUIRE(memory.size() == 2); - // 容量降到 1:只填了第一台,第二台仍在场。 + // Capacity drops to 1: only the first device is filled in, the second is still present. infos = {DeviceInfo(first, "", "", "")}; ReconcileDeviceStrings(memory, both, infos); CHECK(memory.size() == 2); - // 之后第二台重新被填出且读不到时,记忆还在,能回填。 + // When the second device is filled in again later and unreadable, its memory is still + // there and can be backfilled. infos = {DeviceInfo(second, "", "", "")}; ReconcileDeviceStrings(memory, both, infos); CHECK(std::string{infos[0].product} == "Pixel"); @@ -197,324 +206,326 @@ TEST_CASE("ReconcileDeviceStrings 不清除仍在场但没被填出的设备的 } // TEST_SUITE("capi.support") TEST_SUITE("capi.basics") { - -TEST_CASE("tk_version 填出非空的版本串") { - tk_version_info_t version{}; - tk_version(&version); - CHECK(std::strlen(version.text) > 0); - CHECK(std::strlen(version.build) > 0); - CHECK(std::strlen(version.libusb) > 0); - CHECK(version.major + version.minor + version.patch > 0); - - SUBCASE("传 nullptr 是空操作而非崩溃") { - tk_version(nullptr); + TEST_CASE("tk_version fills non-empty version strings") { + tk_version_info_t version{}; + tk_version(&version); + CHECK(std::strlen(version.text) > 0); + CHECK(std::strlen(version.build) > 0); + CHECK(std::strlen(version.libusb) > 0); + CHECK(version.major + version.minor + version.patch > 0); + + SUBCASE("passing nullptr is a no-op and does not crash") { + tk_version(nullptr); + } } -} -TEST_CASE("tk_check_environment 永远成功,只在字段里表达结论") { - tk_environment_t environment{}; - CHECK(tk_check_environment(&environment) == TK_OK); - // 测试进程不是 root,这一条是确定的。 - CHECK_FALSE(environment.is_root); - // sysctl 合格时不应该同时带着说明文字。 - if (environment.sysctls_ok) { - CHECK(std::strlen(environment.sysctl_detail) == 0); - } + TEST_CASE("tk_check_environment always succeeds and expresses conclusions in fields only") { + tk_environment_t environment{}; + CHECK(tk_check_environment(&environment) == TK_OK); + // The test process is not root; this is certain. + CHECK_FALSE(environment.is_root); + // When sysctls are OK, there should be no explanatory text. + if (environment.sysctls_ok) { + CHECK(std::strlen(environment.sysctl_detail) == 0); + } - SUBCASE("传 nullptr 返回参数错误") { - CHECK(tk_check_environment(nullptr) == TK_ERR_INVALID_ARGUMENT); + SUBCASE("passing nullptr returns invalid argument") { + CHECK(tk_check_environment(nullptr) == TK_ERR_INVALID_ARGUMENT); + } } -} -TEST_CASE("tk_list_devices 允许只统计数量") { - std::size_t count = 0; - tk_error_t error{}; - // 开发机上通常没插 RNDIS 设备,这里只验证「调用成功且不写越界」, - // 不断言具体数量。 - const tk_result_t result = tk_list_devices(nullptr, 0, &count, false, &error); - CHECK(result == TK_OK); - CHECK(std::strlen(error.message) == 0); - - SUBCASE("缺少 out_count 时拒绝调用") { - CHECK(tk_list_devices(nullptr, 0, nullptr, false, nullptr) == TK_ERR_INVALID_ARGUMENT); + TEST_CASE("tk_list_devices can count only") { + std::size_t count = 0; + tk_error_t error{}; + // The development machine usually has no RNDIS device inserted; only verify that the call + // succeeds and does not write out of bounds, not the specific count. + const tk_result_t result = tk_list_devices(nullptr, 0, &count, false, &error); + CHECK(result == TK_OK); + CHECK(std::strlen(error.message) == 0); + + SUBCASE("missing out_count is rejected") { + CHECK(tk_list_devices(nullptr, 0, nullptr, false, nullptr) == TK_ERR_INVALID_ARGUMENT); + } } -} -TEST_CASE("重复枚举不会反复初始化 libusb") { - // GUI 会周期性刷新设备列表。若每次枚举都新建一个 libusb 上下文,就会每次都 - // 起停一条事件线程与一条 IOKit runloop 线程,日志里还会被「libusb 已初始化」 - // 刷满 —— 这个问题真实发生过,这条用例把修复钉住。 - tk_enable_log_capture(true); - const tetherkit::LogLevel saved_level = tetherkit::GetLogLevel(); - tetherkit::SetLogLevel(tetherkit::LogLevel::kInfo); - - std::size_t count = 0; - std::array records{}; - std::uint64_t dropped = 0; - - // 先枚举一次并把日志清空,确保共享上下文已经建立(第一次初始化是应该有的)。 - tk_list_devices(nullptr, 0, &count, false, nullptr); - while (tk_drain_logs(records.data(), records.size(), &dropped) > 0) { - } + TEST_CASE("repeated enumeration does not reinitialize libusb") { + // The GUI refreshes the device list periodically. If each enumeration created a new libusb + // context, it would start and stop an event thread and an IOKit runloop thread each time, + // and logs would be flooded with "libusb initialized". This happened in reality; this test + // pins down the fix. + tk_enable_log_capture(true); + const tetherkit::LogLevel saved_level = tetherkit::GetLogLevel(); + tetherkit::SetLogLevel(tetherkit::LogLevel::kInfo); + + std::size_t count = 0; + std::array records{}; + std::uint64_t dropped = 0; + + // Enumerate once and drain logs to ensure the shared context is already established (the + // first initialization is expected). + tk_list_devices(nullptr, 0, &count, false, nullptr); + while (tk_drain_logs(records.data(), records.size(), &dropped) > 0) { + } - constexpr int kRepeats = 3; - for (int i = 0; i < kRepeats; ++i) { - CHECK(tk_list_devices(nullptr, 0, &count, false, nullptr) == TK_OK); - } + constexpr int kRepeats = 3; + for (int i = 0; i < kRepeats; ++i) { + CHECK(tk_list_devices(nullptr, 0, &count, false, nullptr) == TK_OK); + } - std::size_t initialization_lines = 0; - std::size_t taken = 0; - while ((taken = tk_drain_logs(records.data(), records.size(), &dropped)) > 0) { - for (std::size_t i = 0; i < taken; ++i) { - if (std::string{records[i].message}.find("libusb 已初始化") != std::string::npos) { - ++initialization_lines; + std::size_t initialization_lines = 0; + std::size_t taken = 0; + while ((taken = tk_drain_logs(records.data(), records.size(), &dropped)) > 0) { + for (std::size_t i = 0; i < taken; ++i) { + if (std::string{records[i].message}.find("libusb initialized") != std::string::npos) { + ++initialization_lines; + } } } - } - CHECK(initialization_lines == 0); + CHECK(initialization_lines == 0); - tetherkit::SetLogLevel(saved_level); - tk_enable_log_capture(false); -} + tetherkit::SetLogLevel(saved_level); + tk_enable_log_capture(false); + } } // TEST_SUITE("capi.basics") TEST_SUITE("capi.log_ring") { + TEST_CASE("no records are produced when log capture is off") { + tk_enable_log_capture(false); + TETHERKIT_ERROR("this must not be captured"); -TEST_CASE("日志捕获关闭时不产生记录") { - tk_enable_log_capture(false); - TETHERKIT_ERROR("这条不该被捕获"); + std::array records{}; + std::uint64_t dropped = 0; + CHECK(tk_drain_logs(records.data(), records.size(), &dropped) == 0); + } - std::array records{}; - std::uint64_t dropped = 0; - CHECK(tk_drain_logs(records.data(), records.size(), &dropped) == 0); -} + TEST_CASE("logs can be retrieved in order after capture is enabled") { + tk_enable_log_capture(true); + const tetherkit::LogLevel saved_level = tetherkit::GetLogLevel(); + tetherkit::SetLogLevel(tetherkit::LogLevel::kInfo); -TEST_CASE("开启捕获后能按序取回日志") { - tk_enable_log_capture(true); - const tetherkit::LogLevel saved_level = tetherkit::GetLogLevel(); - tetherkit::SetLogLevel(tetherkit::LogLevel::kInfo); + TETHERKIT_INFO("first"); + TETHERKIT_WARN("second"); - TETHERKIT_INFO("第一条"); - TETHERKIT_WARN("第二条"); + std::array records{}; + std::uint64_t dropped = 0; + const std::size_t taken = tk_drain_logs(records.data(), records.size(), &dropped); - std::array records{}; - std::uint64_t dropped = 0; - const std::size_t taken = tk_drain_logs(records.data(), records.size(), &dropped); + CHECK(taken == 2); + CHECK(dropped == 0); + CHECK(std::string{records[0].message} == "first"); + CHECK(records[0].level == TK_LOG_INFO); + CHECK(std::string{records[1].message} == "second"); + CHECK(records[1].level == TK_LOG_WARN); + CHECK(records[0].wall_nanos > 0); - CHECK(taken == 2); - CHECK(dropped == 0); - CHECK(std::string{records[0].message} == "第一条"); - CHECK(records[0].level == TK_LOG_INFO); - CHECK(std::string{records[1].message} == "第二条"); - CHECK(records[1].level == TK_LOG_WARN); - CHECK(records[0].wall_nanos > 0); + SUBCASE("draining again after empty returns 0") { + CHECK(tk_drain_logs(records.data(), records.size(), &dropped) == 0); + } - SUBCASE("取空之后再取返回 0") { - CHECK(tk_drain_logs(records.data(), records.size(), &dropped) == 0); + tetherkit::SetLogLevel(saved_level); + tk_enable_log_capture(false); } - tetherkit::SetLogLevel(saved_level); - tk_enable_log_capture(false); -} + TEST_CASE("drops the oldest entries and reports the drop count when the buffer is full") { + tk_enable_log_capture(true); + const tetherkit::LogLevel saved_level = tetherkit::GetLogLevel(); + tetherkit::SetLogLevel(tetherkit::LogLevel::kInfo); -TEST_CASE("缓冲写满时丢最旧的并汇报丢弃数") { - tk_enable_log_capture(true); - const tetherkit::LogLevel saved_level = tetherkit::GetLogLevel(); - tetherkit::SetLogLevel(tetherkit::LogLevel::kInfo); + // Capacity is 256 (see log_ring.cc); log 10 extra entries to push the oldest out. + constexpr int kOverflow = 10; + constexpr int kTotal = 256 + kOverflow; + for (int i = 0; i < kTotal; ++i) { + TETHERKIT_INFO("entry {}", i); + } - // 容量是 256(见 log_ring.cc),多打 10 条把最旧的挤掉。 - constexpr int kOverflow = 10; - constexpr int kTotal = 256 + kOverflow; - for (int i = 0; i < kTotal; ++i) { - TETHERKIT_INFO("第 {} 条", i); - } + std::array records{}; + std::uint64_t dropped = 0; + const std::size_t taken = tk_drain_logs(records.data(), records.size(), &dropped); - std::array records{}; - std::uint64_t dropped = 0; - const std::size_t taken = tk_drain_logs(records.data(), records.size(), &dropped); + CHECK(taken == 256); + CHECK(dropped == kOverflow); + // The remaining entries should be the newest 256, so the first one is entry 10. + CHECK(std::string{records[0].message} == "entry 10"); + CHECK(std::string{records[taken - 1].message} == "entry 265"); - CHECK(taken == 256); - CHECK(dropped == kOverflow); - // 留下来的应该是**最新**的那 256 条,所以第一条是第 10 条。 - CHECK(std::string{records[0].message} == "第 10 条"); - CHECK(std::string{records[taken - 1].message} == "第 265 条"); + SUBCASE("the drop count resets to zero after being drained") { + std::uint64_t again = 1; + tk_drain_logs(records.data(), records.size(), &again); + CHECK(again == 0); + } - SUBCASE("丢弃计数取走后归零") { - std::uint64_t again = 1; - tk_drain_logs(records.data(), records.size(), &again); - CHECK(again == 0); + tetherkit::SetLogLevel(saved_level); + tk_enable_log_capture(false); } - tetherkit::SetLogLevel(saved_level); - tk_enable_log_capture(false); -} - } // TEST_SUITE("capi.log_ring") TEST_SUITE("capi.session") { + TEST_CASE("tk_session_config_init fills usable defaults") { + tk_session_config_t config{}; + tk_session_config_init(&config); + + CHECK(config.mtu == 1500); + CHECK(config.adopt_device_mac); + CHECK(config.rx_transfer_count > 0); + CHECK(config.tx_transfer_count > 0); + CHECK(config.rx_transfer_kib > 0); + CHECK(config.max_transfer_kib > 0); + CHECK(config.bpf_buffer_kib > 0); + // Device filtering is unrestricted by default, otherwise the GUI would find no device + // on startup. + CHECK(config.vendor_id == 0); + CHECK(config.product_id == 0); + } -TEST_CASE("tk_session_config_init 填出可直接使用的默认值") { - tk_session_config_t config{}; - tk_session_config_init(&config); - - CHECK(config.mtu == 1500); - CHECK(config.adopt_device_mac); - CHECK(config.rx_transfer_count > 0); - CHECK(config.tx_transfer_count > 0); - CHECK(config.rx_transfer_kib > 0); - CHECK(config.max_transfer_kib > 0); - CHECK(config.bpf_buffer_kib > 0); - // 设备筛选默认不限,否则 GUI 一上来就筛不到任何设备。 - CHECK(config.vendor_id == 0); - CHECK(config.product_id == 0); -} + TEST_CASE("session can be created, queried, and destroyed (no root required)") { + tk_session_config_t config{}; + tk_session_config_init(&config); + + tk_error_t error{}; + tk_session_t* session = tk_session_create(&config, &error); + REQUIRE(session != nullptr); + CHECK(std::strlen(error.message) == 0); + + SUBCASE("IDLE before start, with all fields cleanly zeroed") { + tk_session_status_t status{}; + REQUIRE(tk_session_status_get(session, &status) == TK_OK); + CHECK(status.run_state == TK_RUN_IDLE); + CHECK(status.rndis_state == TK_RNDIS_UNINITIALIZED); + CHECK_FALSE(status.link_up); + CHECK(std::strlen(status.system_interface) == 0); + CHECK(std::strlen(status.fatal) == 0); + CHECK(status.rx_frames == 0); + CHECK(status.monotonic_nanos > 0); + } -TEST_CASE("会话可创建、可查状态、可销毁(不需要 root)") { - tk_session_config_t config{}; - tk_session_config_init(&config); - - tk_error_t error{}; - tk_session_t* session = tk_session_create(&config, &error); - REQUIRE(session != nullptr); - CHECK(std::strlen(error.message) == 0); - - SUBCASE("未启动时是 IDLE,且各字段都是干净的零值") { - tk_session_status_t status{}; - REQUIRE(tk_session_status_get(session, &status) == TK_OK); - CHECK(status.run_state == TK_RUN_IDLE); - CHECK(status.rndis_state == TK_RNDIS_UNINITIALIZED); - CHECK_FALSE(status.link_up); - CHECK(std::strlen(status.system_interface) == 0); - CHECK(std::strlen(status.fatal) == 0); - CHECK(status.rx_frames == 0); - CHECK(status.monotonic_nanos > 0); - } + SUBCASE("event queue is empty before start") { + std::array events{}; + CHECK(tk_session_poll_events(session, events.data(), events.size()) == 0); + } - SUBCASE("未启动时事件队列是空的") { - std::array events{}; - CHECK(tk_session_poll_events(session, events.data(), events.size()) == 0); - } + SUBCASE("starting as non-root returns TK_ERR_PERMISSION rather than a generic failure") { + // The GUI relies on this code to distinguish "prompt for authorization" from "a real + // error occurred". + CHECK(tk_session_start(session, &error) == TK_ERR_PERMISSION); + CHECK(std::strlen(error.message) > 0); + } - SUBCASE("非 root 启动返回 TK_ERR_PERMISSION 而不是笼统的失败") { - // GUI 靠这个码区分「该弹授权」和「真的出错了」。 - CHECK(tk_session_start(session, &error) == TK_ERR_PERMISSION); - CHECK(std::strlen(error.message) > 0); - } + SUBCASE("repeated stop is idempotent") { + CHECK(tk_session_stop(session) == TK_OK); + CHECK(tk_session_stop(session) == TK_OK); + } - SUBCASE("重复 stop 是幂等的") { - CHECK(tk_session_stop(session) == TK_OK); - CHECK(tk_session_stop(session) == TK_OK); + tk_session_destroy(session); } - tk_session_destroy(session); -} + TEST_CASE("session API is safe for null pointers") { + tk_error_t error{}; + CHECK(tk_session_create(nullptr, &error) == nullptr); + CHECK(std::strlen(error.message) > 0); -TEST_CASE("会话接口对空指针一律安全") { - tk_error_t error{}; - CHECK(tk_session_create(nullptr, &error) == nullptr); - CHECK(std::strlen(error.message) > 0); - - CHECK(tk_session_start(nullptr, nullptr) == TK_ERR_INVALID_ARGUMENT); - CHECK(tk_session_stop(nullptr) == TK_ERR_INVALID_ARGUMENT); - CHECK(tk_session_status_get(nullptr, nullptr) == TK_ERR_INVALID_ARGUMENT); - CHECK(tk_session_poll_events(nullptr, nullptr, 4) == 0); - tk_session_destroy(nullptr); // 不崩即通过 - tk_session_config_init(nullptr); -} + CHECK(tk_session_start(nullptr, nullptr) == TK_ERR_INVALID_ARGUMENT); + CHECK(tk_session_stop(nullptr) == TK_ERR_INVALID_ARGUMENT); + CHECK(tk_session_status_get(nullptr, nullptr) == TK_ERR_INVALID_ARGUMENT); + CHECK(tk_session_poll_events(nullptr, nullptr, 4) == 0); + tk_session_destroy(nullptr); // No crash means it passed. + tk_session_config_init(nullptr); + } } // TEST_SUITE("capi.session") TEST_SUITE("capi.process") { + TEST_CASE("RunTool collects child output and carries the exit code") { + const auto result = tetherkit::capi::RunTool("/bin/echo", {"hello", "world"}); + REQUIRE(result.has_value()); + CHECK(result->exit_code == 0); + CHECK(result->Succeeded()); + CHECK(result->output == "hello world\n"); + } -TEST_CASE("RunTool 收集子进程输出并带回退出码") { - const auto result = tetherkit::capi::RunTool("/bin/echo", {"你好", "世界"}); - REQUIRE(result.has_value()); - CHECK(result->exit_code == 0); - CHECK(result->Succeeded()); - CHECK(result->output == "你好 世界\n"); -} - -TEST_CASE("RunTool 合并 stderr,且非零退出不算调用失败") { - // sh -c 'echo boom >&2; exit 3':验证 stderr 也被收进来、退出码原样带回。 - const auto result = - tetherkit::capi::RunTool("/bin/sh", {"-c", "echo boom >&2; exit 3"}); - REQUIRE(result.has_value()); - CHECK_FALSE(result->Succeeded()); - CHECK(result->exit_code == 3); - CHECK(result->output == "boom\n"); -} + TEST_CASE("RunTool merges stderr, and a non-zero exit is not treated as a call failure") { + // sh -c 'echo boom >&2; exit 3': verify stderr is also collected and the exit code is + // returned as-is. + const auto result = tetherkit::capi::RunTool("/bin/sh", {"-c", "echo boom >&2; exit 3"}); + REQUIRE(result.has_value()); + CHECK_FALSE(result->Succeeded()); + CHECK(result->exit_code == 3); + CHECK(result->output == "boom\n"); + } -TEST_CASE("RunTool 读得下超过管道缓冲的大输出(不死锁)") { - // 管道缓冲是 64 KiB。必须先读空再 waitpid,否则子进程写满就阻塞、 - // 我们等在 waitpid 上,双方僵住。这条用例就是钉住那个顺序的。 - const auto result = tetherkit::capi::RunTool( - "/bin/sh", {"-c", "for i in $(seq 1 20000); do echo 0123456789; done"}); - REQUIRE(result.has_value()); - CHECK(result->exit_code == 0); - CHECK(result->output.size() == 20000 * 11); -} + TEST_CASE("RunTool can read output larger than the pipe buffer (no deadlock)") { + // The pipe buffer is 64 KiB. We must drain it before waitpid, otherwise the child blocks + // once the pipe is full while we wait in waitpid and both sides deadlock. This test pins + // down that order. + const auto result = tetherkit::capi::RunTool( + "/bin/sh", {"-c", "for i in $(seq 1 20000); do echo 0123456789; done"}); + REQUIRE(result.has_value()); + CHECK(result->exit_code == 0); + CHECK(result->output.size() == 20000 * 11); + } -TEST_CASE("RunTool 对不存在的可执行文件返回错误而非崩溃") { - const auto result = tetherkit::capi::RunTool("/nonexistent/tetherkit-test", {}); - CHECK_FALSE(result.has_value()); -} + TEST_CASE("RunTool returns an error rather than crashing for a nonexistent executable") { + const auto result = tetherkit::capi::RunTool("/nonexistent/tetherkit-test", {}); + CHECK_FALSE(result.has_value()); + } } // TEST_SUITE("capi.process") TEST_SUITE("capi.net_config") { + TEST_CASE("tk_ip_config_init defaults to DHCP and does not take over the global default route") { + tk_ip_config_t config{}; + tk_ip_config_init(&config); + CHECK(config.mode == TK_IP_MODE_DHCP); + CHECK_FALSE(config.set_default_route); + CHECK(config.dns_count == 0); + CHECK(std::strlen(config.address) == 0); + } -TEST_CASE("tk_ip_config_init 默认 DHCP 且不抢全局默认路由") { - tk_ip_config_t config{}; - tk_ip_config_init(&config); - CHECK(config.mode == TK_IP_MODE_DHCP); - CHECK_FALSE(config.set_default_route); - CHECK(config.dns_count == 0); - CHECK(std::strlen(config.address) == 0); -} - -TEST_CASE("拒绝对非 feth 网卡下手") { - tk_ip_config_t config{}; - tk_ip_config_init(&config); - tk_error_t error{}; + TEST_CASE("rejects operating on non-feth interfaces") { + tk_ip_config_t config{}; + tk_ip_config_init(&config); + tk_error_t error{}; - // 这是本模块最重要的一条防线:误传 en0 会把用户的 Wi-Fi 配置冲掉。 - CHECK(tk_net_apply("en0", &config, &error) == TK_ERR_INVALID_ARGUMENT); - CHECK(std::string{error.message}.find("en0") != std::string::npos); + // This is the most important guardrail in this module: passing en0 by mistake would + // overwrite the user's Wi-Fi configuration. + CHECK(tk_net_apply("en0", &config, &error) == TK_ERR_INVALID_ARGUMENT); + CHECK(std::string{error.message}.find("en0") != std::string::npos); - CHECK(tk_net_clear("en0", &error) == TK_ERR_INVALID_ARGUMENT); - CHECK(tk_net_query("en0", nullptr, &error) == TK_ERR_INVALID_ARGUMENT); + CHECK(tk_net_clear("en0", &error) == TK_ERR_INVALID_ARGUMENT); + CHECK(tk_net_query("en0", nullptr, &error) == TK_ERR_INVALID_ARGUMENT); - tk_net_state_t state{}; - CHECK(tk_net_query("bridge0", &state, &error) == TK_ERR_INVALID_ARGUMENT); - CHECK(tk_net_apply(nullptr, &config, &error) == TK_ERR_INVALID_ARGUMENT); - CHECK(tk_net_apply("feth0", nullptr, &error) == TK_ERR_INVALID_ARGUMENT); -} + tk_net_state_t state{}; + CHECK(tk_net_query("bridge0", &state, &error) == TK_ERR_INVALID_ARGUMENT); + CHECK(tk_net_apply(nullptr, &config, &error) == TK_ERR_INVALID_ARGUMENT); + CHECK(tk_net_apply("feth0", nullptr, &error) == TK_ERR_INVALID_ARGUMENT); + } -TEST_CASE("非 root 下写操作返回 TK_ERR_PERMISSION") { - tk_ip_config_t config{}; - tk_ip_config_init(&config); - tk_error_t error{}; + TEST_CASE("write operations return TK_ERR_PERMISSION when not root") { + tk_ip_config_t config{}; + tk_ip_config_init(&config); + tk_error_t error{}; - CHECK(tk_net_apply("feth9", &config, &error) == TK_ERR_PERMISSION); - CHECK(std::strlen(error.message) > 0); - CHECK(tk_net_clear("feth9", &error) == TK_ERR_PERMISSION); + CHECK(tk_net_apply("feth9", &config, &error) == TK_ERR_PERMISSION); + CHECK(std::strlen(error.message) > 0); + CHECK(tk_net_clear("feth9", &error) == TK_ERR_PERMISSION); - std::size_t removed = 1; - CHECK(tk_cleanup_orphan_interfaces(&removed, &error) == TK_ERR_PERMISSION); - CHECK(removed == 0); -} + std::size_t removed = 1; + CHECK(tk_cleanup_orphan_interfaces(&removed, &error) == TK_ERR_PERMISSION); + CHECK(removed == 0); + } -TEST_CASE("查询不存在的 feth 网卡是成功且全空,而不是报错") { - // GUI 在会话没起来时也会刷新网络状态,那时网卡还不存在 —— 这种情况必须是 - // 「没有地址」而不是「查询失败」,否则界面上会一直挂着一个假的错误。 - tk_net_state_t state{}; - tk_error_t error{}; - CHECK(tk_net_query("feth99", &state, &error) == TK_OK); - CHECK_FALSE(state.has_address); - CHECK(state.dns_count == 0); - CHECK_FALSE(state.is_primary_default_route); - CHECK(std::strlen(error.message) == 0); -} + TEST_CASE("querying a nonexistent feth interface succeeds and is empty rather than failing") { + // The GUI also refreshes network status before the session is up, when the interface does + // not exist yet. That case must be "no address" rather than "query failed", otherwise the + // UI would show a spurious error. + tk_net_state_t state{}; + tk_error_t error{}; + CHECK(tk_net_query("feth99", &state, &error) == TK_OK); + CHECK_FALSE(state.has_address); + CHECK(state.dns_count == 0); + CHECK_FALSE(state.is_primary_default_route); + CHECK(std::strlen(error.message) == 0); + } } // TEST_SUITE("capi.net_config") diff --git a/tests/test_common_byte_order.cc b/tests/test_common_byte_order.cc index 55ce72b..c30d051 100644 --- a/tests/test_common_byte_order.cc +++ b/tests/test_common_byte_order.cc @@ -1,6 +1,7 @@ -// byte_order.h 的单元测试。 +// Unit tests for byte_order.h. // -// 重点验证:未对齐访问必须正确(RNDIS 消息在 USB 缓冲里的偏移不保证对齐)。 +// Key verification: unaligned access must be correct (offsets of RNDIS messages in USB +// buffers are not guaranteed to be aligned). #include #include #include @@ -20,74 +21,74 @@ using tetherkit::StoreLe32; using tetherkit::StoreLe64; TEST_SUITE("common.byte_order") { + TEST_CASE("little-endian reads interpret byte order as specified") { + // The little-endian representation of 0x44332211 is 11 22 33 44. + const std::array bytes{std::byte{0x11}, std::byte{0x22}, std::byte{0x33}, + std::byte{0x44}, std::byte{0x55}, std::byte{0x66}, + std::byte{0x77}, std::byte{0x88}}; + CHECK(LoadLe16(bytes.data()) == 0x2211U); + CHECK(LoadLe32(bytes.data()) == 0x44332211U); + CHECK(LoadLe64(bytes.data()) == 0x8877665544332211ULL); + } -TEST_CASE("小端读取按规范解释字节序") { - // 0x44332211 的小端表示是 11 22 33 44。 - const std::array bytes{std::byte{0x11}, std::byte{0x22}, std::byte{0x33}, - std::byte{0x44}, std::byte{0x55}, std::byte{0x66}, - std::byte{0x77}, std::byte{0x88}}; - CHECK(LoadLe16(bytes.data()) == 0x2211U); - CHECK(LoadLe32(bytes.data()) == 0x44332211U); - CHECK(LoadLe64(bytes.data()) == 0x8877665544332211ULL); -} - -TEST_CASE("大端读取按网络字节序解释") { - const std::array bytes{std::byte{0x08}, std::byte{0x00}}; - // 0x0800 = ETHERTYPE_IP - CHECK(LoadBe16(bytes.data()) == 0x0800U); -} + TEST_CASE("big-endian reads interpret network byte order") { + const std::array bytes{std::byte{0x08}, std::byte{0x00}}; + // 0x0800 = ETHERTYPE_IP + CHECK(LoadBe16(bytes.data()) == 0x0800U); + } -TEST_CASE("写入后读回保持一致") { - std::array buffer{}; - StoreLe32(buffer.data(), 0xDEADBEEFU); - CHECK(LoadLe32(buffer.data()) == 0xDEADBEEFU); + TEST_CASE("read-back after write is consistent") { + std::array buffer{}; + StoreLe32(buffer.data(), 0xDEADBEEFU); + CHECK(LoadLe32(buffer.data()) == 0xDEADBEEFU); - StoreLe64(buffer.data() + 8, 0x0123456789ABCDEFULL); - CHECK(LoadLe64(buffer.data() + 8) == 0x0123456789ABCDEFULL); + StoreLe64(buffer.data() + 8, 0x0123456789ABCDEFULL); + CHECK(LoadLe64(buffer.data() + 8) == 0x0123456789ABCDEFULL); - StoreBe16(buffer.data() + 4, 0x86DDU); // ETHERTYPE_IPV6 - CHECK(LoadBe16(buffer.data() + 4) == 0x86DDU); - // 大端写入的字节顺序必须是高位在前。 - CHECK(buffer[4] == std::byte{0x86}); - CHECK(buffer[5] == std::byte{0xDD}); -} + StoreBe16(buffer.data() + 4, 0x86DDU); // ETHERTYPE_IPV6 + CHECK(LoadBe16(buffer.data() + 4) == 0x86DDU); + // Big-endian writes must place the high byte first. + CHECK(buffer[4] == std::byte{0x86}); + CHECK(buffer[5] == std::byte{0xDD}); + } -TEST_CASE("未对齐偏移上的读写同样正确") { - // 这是本项目最容易出错的场景:REMOTE_NDIS_PACKET_MSG 之后的以太帧 - // 起始偏移由设备的 DataOffset 决定,可能是任意值。 - std::array buffer{}; - for (std::size_t offset = 0; offset < 8; ++offset) { - StoreLe32(buffer.data() + offset, 0xCAFEBABEU); - CHECK(LoadLe32(buffer.data() + offset) == 0xCAFEBABEU); + TEST_CASE("reads and writes at unaligned offsets are also correct") { + // This is the most error-prone scenario in this project: the start offset of the + // Ethernet frame after REMOTE_NDIS_PACKET_MSG is determined by the device's DataOffset + // and can be any value. + std::array buffer{}; + for (std::size_t offset = 0; offset < 8; ++offset) { + StoreLe32(buffer.data() + offset, 0xCAFEBABEU); + CHECK(LoadLe32(buffer.data() + offset) == 0xCAFEBABEU); - StoreLe64(buffer.data() + offset + 8, 0xFEEDFACEDEADBEEFULL); - CHECK(LoadLe64(buffer.data() + offset + 8) == 0xFEEDFACEDEADBEEFULL); + StoreLe64(buffer.data() + offset + 8, 0xFEEDFACEDEADBEEFULL); + CHECK(LoadLe64(buffer.data() + offset + 8) == 0xFEEDFACEDEADBEEFULL); + } } -} -TEST_CASE("单字节读写不做任何转换") { - std::array buffer{}; - tetherkit::StoreLe(buffer.data(), 0xA5U); - CHECK(tetherkit::LoadLe(buffer.data()) == 0xA5U); -} + TEST_CASE("single-byte reads and writes perform no conversion") { + std::array buffer{}; + tetherkit::StoreLe(buffer.data(), 0xA5U); + CHECK(tetherkit::LoadLe(buffer.data()) == 0xA5U); + } -TEST_CASE("AlignUp 向上对齐到 2 的幂") { - CHECK(AlignUp(0, 4) == 0); - CHECK(AlignUp(1, 4) == 4); - CHECK(AlignUp(4, 4) == 4); - CHECK(AlignUp(5, 4) == 8); - // RNDIS 多包聚合要求每条消息按 4 字节对齐。 - CHECK(AlignUp(1514 + 44, 4) == 1560); - CHECK(AlignUp(63, 64) == 64); -} + TEST_CASE("AlignUp rounds up to a power of two") { + CHECK(AlignUp(0, 4) == 0); + CHECK(AlignUp(1, 4) == 4); + CHECK(AlignUp(4, 4) == 4); + CHECK(AlignUp(5, 4) == 8); + // RNDIS multi-packet aggregation requires each message to be aligned to 4 bytes. + CHECK(AlignUp(1514 + 44, 4) == 1560); + CHECK(AlignUp(63, 64) == 64); + } -TEST_CASE("IsPowerOfTwo 正确识别 2 的幂") { - CHECK_FALSE(IsPowerOfTwo(0)); - CHECK(IsPowerOfTwo(1)); - CHECK(IsPowerOfTwo(2)); - CHECK_FALSE(IsPowerOfTwo(3)); - CHECK(IsPowerOfTwo(1024)); - CHECK_FALSE(IsPowerOfTwo(1023)); -} + TEST_CASE("IsPowerOfTwo correctly identifies powers of two") { + CHECK_FALSE(IsPowerOfTwo(0)); + CHECK(IsPowerOfTwo(1)); + CHECK(IsPowerOfTwo(2)); + CHECK_FALSE(IsPowerOfTwo(3)); + CHECK(IsPowerOfTwo(1024)); + CHECK_FALSE(IsPowerOfTwo(1023)); + } } // TEST_SUITE("common.byte_order") diff --git a/tests/test_common_error.cc b/tests/test_common_error.cc index 96717f4..57da352 100644 --- a/tests/test_common_error.cc +++ b/tests/test_common_error.cc @@ -1,4 +1,4 @@ -// error.h / logging.h 的单元测试。 +// Unit tests for error.h / logging.h. #include #include @@ -17,14 +17,14 @@ using tetherkit::Status; namespace { Status FailingStep() { - return std::unexpected(Error::FromErrno(ENOENT, "打开 /dev/bpf0 失败")); + return std::unexpected(Error::FromErrno(ENOENT, "failed to open /dev/bpf0")); } Status SucceedingStep() { return Ok(); } -/// 演示 TETHERKIT_RETURN_IF_ERROR 的传播链。 +/// Demonstrates the TETHERKIT_RETURN_IF_ERROR propagation chain. Status PipelineWithFailure() { TETHERKIT_RETURN_IF_ERROR(SucceedingStep()); TETHERKIT_RETURN_IF_ERROR(FailingStep()); @@ -33,7 +33,7 @@ Status PipelineWithFailure() { Result ProducingStep(bool succeed) { if (!succeed) { - return std::unexpected(Error::Generic("生产失败")); + return std::unexpected(Error::Generic("production failed")); } return 42; } @@ -46,90 +46,90 @@ Result ConsumingPipeline(bool succeed) { } // namespace TEST_SUITE("common.error") { + TEST_CASE("errno domain renders symbol name and numeric value") { + const Error error = Error::FromErrno(EACCES, "failed to open BPF device"); + CHECK(error.Domain() == ErrorDomain::kErrno); + CHECK(error.Code() == EACCES); + const std::string text = error.ToString(); + CHECK(text.find("failed to open BPF device") != std::string::npos); + CHECK(text.find("errno") != std::string::npos); + CHECK(text.find(std::to_string(EACCES)) != std::string::npos); + } -TEST_CASE("errno 域渲染出符号名与数值") { - const Error error = Error::FromErrno(EACCES, "打开 BPF 设备失败"); - CHECK(error.Domain() == ErrorDomain::kErrno); - CHECK(error.Code() == EACCES); - const std::string text = error.ToString(); - CHECK(text.find("打开 BPF 设备失败") != std::string::npos); - CHECK(text.find("errno") != std::string::npos); - CHECK(text.find(std::to_string(EACCES)) != std::string::npos); -} - -TEST_CASE("errno 传 0 时自动读取全局 errno") { - errno = EPERM; - const Error error = Error::FromErrno(0, "创建 feth 失败"); - CHECK(error.Code() == EPERM); -} + TEST_CASE("passing 0 as errno reads the global errno automatically") { + errno = EPERM; + const Error error = Error::FromErrno(0, "failed to create feth"); + CHECK(error.Code() == EPERM); + } -TEST_CASE("libusb 域渲染出 libusb 错误名") { - // LIBUSB_ERROR_ACCESS == -3,是本项目最常见的失败(未以 root 运行)。 - const Error error = Error::FromLibUsb(-3, "声明 RNDIS 数据接口失败"); - CHECK(error.Domain() == ErrorDomain::kLibUsb); - const std::string text = error.ToString(); - CHECK(text.find("LIBUSB_ERROR_ACCESS") != std::string::npos); -} + TEST_CASE("libusb domain renders the libusb error name") { + // LIBUSB_ERROR_ACCESS == -3 is the most common failure in this project (not running as root). + const Error error = Error::FromLibUsb(-3, "failed to claim RNDIS data interface"); + CHECK(error.Domain() == ErrorDomain::kLibUsb); + const std::string text = error.ToString(); + CHECK(text.find("LIBUSB_ERROR_ACCESS") != std::string::npos); + } -TEST_CASE("RNDIS 域输出原始状态码数值") { - // tk_common **刻意不认识** RNDIS 状态码的符号名 —— 那张表只存在于 - // rndis/protocol.cc 一处(单一来源,避免两处不一致)。因此 Error::ToString() - // 这里只负责输出十六进制数值;符号名由 rndis 层在构造 Error 时拼进上下文串 - // (见 rndis/messages.cc 的 MakeStatusError)。 - const Error error = Error::FromRndisStatus(0xC00000BBU, "查询 OID 失败"); - const std::string text = error.ToString(); - CHECK(text.find("查询 OID 失败") != std::string::npos); - CHECK(text.find("0xc00000bb") != std::string::npos); - CHECK(error.Domain() == ErrorDomain::kRndis); - CHECK(error.Code() == 0xC00000BB); -} + TEST_CASE("RNDIS domain outputs the raw status code value") { + // tk_common deliberately does not know the symbolic names of RNDIS status codes -- that + // table lives in only one place, rndis/protocol.cc (single source, avoids inconsistencies). + // Therefore Error::ToString() here only outputs the hexadecimal value; the symbolic name is + // appended to the context string by the rndis layer when constructing the Error (see + // MakeStatusError in rndis/messages.cc). + const Error error = Error::FromRndisStatus(0xC00000BBU, "failed to query OID"); + const std::string text = error.ToString(); + CHECK(text.find("failed to query OID") != std::string::npos); + CHECK(text.find("0xc00000bb") != std::string::npos); + CHECK(error.Domain() == ErrorDomain::kRndis); + CHECK(error.Code() == 0xC00000BB); + } -TEST_CASE("WithContext 形成外层到内层的原因链") { - Error inner = Error::FromErrno(ENODEV, "ioctl(BIOCSETIF) 失败"); - const Error outer = std::move(inner).WithContext("绑定 BPF 到 feth1"); - const std::string text = outer.ToString(); - const auto outer_pos = text.find("绑定 BPF 到 feth1"); - const auto inner_pos = text.find("ioctl(BIOCSETIF) 失败"); - REQUIRE(outer_pos != std::string::npos); - REQUIRE(inner_pos != std::string::npos); - CHECK(outer_pos < inner_pos); // 外层原因在前 -} + TEST_CASE("WithContext forms an outer-to-inner cause chain") { + Error inner = Error::FromErrno(ENODEV, "ioctl(BIOCSETIF) failed"); + const Error outer = std::move(inner).WithContext("failed to attach BPF to feth1"); + const std::string text = outer.ToString(); + const auto outer_pos = text.find("failed to attach BPF to feth1"); + const auto inner_pos = text.find("ioctl(BIOCSETIF) failed"); + REQUIRE(outer_pos != std::string::npos); + REQUIRE(inner_pos != std::string::npos); + CHECK(outer_pos < inner_pos); // the outer cause comes first + } -TEST_CASE("RETURN_IF_ERROR 传播首个失败") { - const Status status = PipelineWithFailure(); - REQUIRE_FALSE(status.has_value()); - CHECK(status.error().Code() == ENOENT); -} + TEST_CASE("RETURN_IF_ERROR propagates the first failure") { + const Status status = PipelineWithFailure(); + REQUIRE_FALSE(status.has_value()); + CHECK(status.error().Code() == ENOENT); + } -TEST_CASE("ASSIGN_OR_RETURN 成功时取值、失败时传播") { - const Result good = ConsumingPipeline(true); - REQUIRE(good.has_value()); - CHECK(good.value() == 84); + TEST_CASE("ASSIGN_OR_RETURN takes the value on success and propagates on failure") { + const Result good = ConsumingPipeline(true); + REQUIRE(good.has_value()); + CHECK(good.value() == 84); - const Result bad = ConsumingPipeline(false); - REQUIRE_FALSE(bad.has_value()); - CHECK(bad.error().Context() == "生产失败"); -} + const Result bad = ConsumingPipeline(false); + REQUIRE_FALSE(bad.has_value()); + CHECK(bad.error().Context() == "production failed"); + } -TEST_CASE("日志级别设置与判断") { - const LogLevel original = tetherkit::GetLogLevel(); + TEST_CASE("log level setting and checking") { + const LogLevel original = tetherkit::GetLogLevel(); - tetherkit::SetLogLevel(LogLevel::kWarn); - CHECK(tetherkit::GetLogLevel() == LogLevel::kWarn); - CHECK_FALSE(tetherkit::detail::IsLogLevelEnabled(LogLevel::kInfo)); - CHECK(tetherkit::detail::IsLogLevelEnabled(LogLevel::kWarn)); - CHECK(tetherkit::detail::IsLogLevelEnabled(LogLevel::kError)); + tetherkit::SetLogLevel(LogLevel::kWarn); + CHECK(tetherkit::GetLogLevel() == LogLevel::kWarn); + CHECK_FALSE(tetherkit::detail::IsLogLevelEnabled(LogLevel::kInfo)); + CHECK(tetherkit::detail::IsLogLevelEnabled(LogLevel::kWarn)); + CHECK(tetherkit::detail::IsLogLevelEnabled(LogLevel::kError)); - tetherkit::SetLogLevel(LogLevel::kOff); - CHECK_FALSE(tetherkit::detail::IsLogLevelEnabled(LogLevel::kError)); + tetherkit::SetLogLevel(LogLevel::kOff); + CHECK_FALSE(tetherkit::detail::IsLogLevelEnabled(LogLevel::kError)); - tetherkit::SetLogLevel(original); -} + tetherkit::SetLogLevel(original); + } -TEST_CASE("从 __FILE__ 截出文件名") { - CHECK(tetherkit::detail::BaseName("/a/b/c/bpf_link.cc") == "bpf_link.cc"); - CHECK(tetherkit::detail::BaseName("bpf_link.cc") == "bpf_link.cc"); - CHECK(tetherkit::detail::BaseName("") == ""); -} + TEST_CASE("extract the file name from __FILE__") { + CHECK(tetherkit::detail::BaseName("/a/b/c/bpf_link.cc") == "bpf_link.cc"); + CHECK(tetherkit::detail::BaseName("bpf_link.cc") == "bpf_link.cc"); + CHECK(tetherkit::detail::BaseName("") == ""); + } } // TEST_SUITE("common.error") diff --git a/tests/test_common_frame_ring.cc b/tests/test_common_frame_ring.cc index 29318d2..86dffe0 100644 --- a/tests/test_common_frame_ring.cc +++ b/tests/test_common_frame_ring.cc @@ -1,7 +1,8 @@ -// FrameRing 的单元测试。 +// Unit tests for FrameRing. // -// FrameRing 是 RX 路径的核心:libusb 回调线程把拆出的以太帧写进去, -// BPF 写线程取出来 write()。因此这里既测语义,也测并发。 +// FrameRing is the core of the RX path: the libusb callback thread writes extracted Ethernet +// frames into it, and the BPF write thread takes them out and calls write(). Therefore this +// tests both semantics and concurrency. #include #include #include @@ -20,7 +21,8 @@ using tetherkit::kCacheLineSize; namespace { -/// 造一个内容可自校验的帧:首字节是序号,其余按序号推导。 +/// Creates a frame whose contents are self-checking: the first byte is the sequence number +/// and the rest are derived from it. std::vector MakeFrame(std::uint32_t length, std::uint8_t tag) { std::vector frame(length); for (std::uint32_t i = 0; i < length; ++i) { @@ -41,315 +43,316 @@ bool VerifyFrame(std::span frame, std::uint8_t tag) { } // namespace TEST_SUITE("common.frame_ring") { + TEST_CASE("capacity rounds up and storage overhead is reported") { + FrameRing ring(100, 1514); + CHECK(ring.Capacity() == 128); + CHECK(ring.MaxFrameBytes() == 1514); + // Each slot = 128-byte header + 1514-byte data = 1642, rounded up to a multiple of 128 = 1664. + CHECK(ring.StorageBytes() == 128 * 1664); + } -TEST_CASE("容量向上取整并报告存储开销") { - FrameRing ring(100, 1514); - CHECK(ring.Capacity() == 128); - CHECK(ring.MaxFrameBytes() == 1514); - // 每槽 = 128 字节头 + 1514 字节数据 = 1642,向上对齐到 128 的倍数 = 1664。 - CHECK(ring.StorageBytes() == 128 * 1664); -} - -TEST_CASE("空队列读取返回空视图") { - FrameRing ring(4, 1514); - const FrameView view = ring.BeginRead(); - CHECK(view.Empty()); - CHECK(view.data == nullptr); -} - -TEST_CASE("单帧入队出队内容一致") { - FrameRing ring(4, 1514); - const std::vector frame = MakeFrame(1514, 0x42); - REQUIRE(ring.TryPush(frame)); - - const FrameView view = ring.BeginRead(); - REQUIRE(view.length == 1514); - CHECK(VerifyFrame(view.Bytes(), 0x42)); - ring.CommitRead(); + TEST_CASE("reading from an empty queue returns an empty view") { + FrameRing ring(4, 1514); + const FrameView view = ring.BeginRead(); + CHECK(view.Empty()); + CHECK(view.data == nullptr); + } - CHECK(ring.BeginRead().Empty()); -} + TEST_CASE("single-frame enqueue and dequeue preserve contents") { + FrameRing ring(4, 1514); + const std::vector frame = MakeFrame(1514, 0x42); + REQUIRE(ring.TryPush(frame)); -TEST_CASE("超长帧被拒绝且不占用槽位") { - FrameRing ring(4, 128); - const std::vector too_long = MakeFrame(129, 0x01); - CHECK_FALSE(ring.TryPush(too_long)); - CHECK(ring.SizeSnapshot() == 0); - // 拒绝一次之后队列仍然可用。 - CHECK(ring.TryPush(MakeFrame(128, 0x02))); -} + const FrameView view = ring.BeginRead(); + REQUIRE(view.length == 1514); + CHECK(VerifyFrame(view.Bytes(), 0x42)); + ring.CommitRead(); -TEST_CASE("队列满后拒绝入队") { - FrameRing ring(4, 64); - for (std::uint8_t i = 0; i < 4; ++i) { - REQUIRE(ring.TryPush(MakeFrame(64, i))); + CHECK(ring.BeginRead().Empty()); } - CHECK(ring.SizeSnapshot() == 4); - CHECK_FALSE(ring.TryPush(MakeFrame(64, 9))); -} -TEST_CASE("BeginWrite/CommitWrite 零拷贝路径") { - FrameRing ring(4, 1514); - const std::span dst = ring.BeginWrite(); - REQUIRE(dst.size() == 1514); - // 直接在预留槽位上原地构造,模拟从 USB 缓冲 memcpy 进来。 - for (std::size_t i = 0; i < 100; ++i) { - dst[i] = std::byte{static_cast((0x77 + i) & 0xFFU)}; + TEST_CASE("oversized frames are rejected and do not occupy a slot") { + FrameRing ring(4, 128); + const std::vector too_long = MakeFrame(129, 0x01); + CHECK_FALSE(ring.TryPush(too_long)); + CHECK(ring.SizeSnapshot() == 0); + // The queue remains usable after a rejection. + CHECK(ring.TryPush(MakeFrame(128, 0x02))); } - ring.CommitWrite(100); - const FrameView view = ring.BeginRead(); - REQUIRE(view.length == 100); - CHECK(VerifyFrame(view.Bytes(), 0x77)); - ring.CommitRead(); -} + TEST_CASE("enqueue is rejected when the queue is full") { + FrameRing ring(4, 64); + for (std::uint8_t i = 0; i < 4; ++i) { + REQUIRE(ring.TryPush(MakeFrame(64, i))); + } + CHECK(ring.SizeSnapshot() == 4); + CHECK_FALSE(ring.TryPush(MakeFrame(64, 9))); + } -TEST_CASE("变长帧混合、反复绕环不串数据") { - FrameRing ring(4, 2048); - // 容量 4,跑 500 轮,每轮长度不同,逼迫索引回绕并复用槽位。 - for (std::uint32_t round = 0; round < 500; ++round) { - const auto length = static_cast(14 + (round * 37) % 2000); - const auto tag = static_cast(round & 0xFFU); - REQUIRE(ring.TryPush(MakeFrame(length, tag))); + TEST_CASE("BeginWrite/CommitWrite zero-copy path") { + FrameRing ring(4, 1514); + const std::span dst = ring.BeginWrite(); + REQUIRE(dst.size() == 1514); + // Construct in place directly in the reserved slot, simulating a memcpy from a USB buffer. + for (std::size_t i = 0; i < 100; ++i) { + dst[i] = std::byte{static_cast((0x77 + i) & 0xFFU)}; + } + ring.CommitWrite(100); const FrameView view = ring.BeginRead(); - REQUIRE(view.length == length); - CHECK(VerifyFrame(view.Bytes(), tag)); + REQUIRE(view.length == 100); + CHECK(VerifyFrame(view.Bytes(), 0x77)); ring.CommitRead(); } -} -TEST_CASE("每帧数据区起始地址按缓存行对齐") { - // 性能正确性断言:帧数据必须落在缓存行边界,memcpy 与 write() 才走最优路径。 - FrameRing ring(8, 1514); - for (int i = 0; i < 8; ++i) { - const std::span dst = ring.BeginWrite(); - REQUIRE_FALSE(dst.empty()); - CHECK(reinterpret_cast(dst.data()) % kCacheLineSize == 0); - ring.CommitWrite(64); + TEST_CASE("mixed variable-length frames with repeated wrap-around do not corrupt data") { + FrameRing ring(4, 2048); + // Capacity 4, run 500 rounds with different lengths each round, forcing indices to wrap and + // reuse slots. + for (std::uint32_t round = 0; round < 500; ++round) { + const auto length = static_cast(14 + (round * 37) % 2000); + const auto tag = static_cast(round & 0xFFU); + REQUIRE(ring.TryPush(MakeFrame(length, tag))); + + const FrameView view = ring.BeginRead(); + REQUIRE(view.length == length); + CHECK(VerifyFrame(view.Bytes(), tag)); + ring.CommitRead(); + } } -} -TEST_CASE("并发搬运 20 万帧不丢不乱序") { - constexpr std::uint32_t kTotal = 200'000; - FrameRing ring(512, 512); + TEST_CASE("each frame data area starts on a cache-line-aligned address") { + // Performance-correctness assertion: frame data must fall on a cache-line boundary so memcpy + // and write() take the optimal path. + FrameRing ring(8, 1514); + for (int i = 0; i < 8; ++i) { + const std::span dst = ring.BeginWrite(); + REQUIRE_FALSE(dst.empty()); + CHECK(reinterpret_cast(dst.data()) % kCacheLineSize == 0); + ring.CommitWrite(64); + } + } - std::atomic corrupted{false}; + TEST_CASE("concurrent transfer of 200,000 frames without loss or reordering") { + constexpr std::uint32_t kTotal = 200'000; + FrameRing ring(512, 512); - std::thread producer([&] { - std::uint32_t sent = 0; - while (sent < kTotal) { - const std::span dst = ring.BeginWrite(); - if (dst.empty()) { + std::atomic corrupted{false}; + + std::thread producer([&] { + std::uint32_t sent = 0; + while (sent < kTotal) { + const std::span dst = ring.BeginWrite(); + if (dst.empty()) { + std::this_thread::yield(); + continue; + } + // Length and contents are both determined by the sequence number so the consumer can + // verify them independently. + const auto length = static_cast(14 + (sent % 400)); + const auto tag = static_cast(sent & 0xFFU); + for (std::uint32_t i = 0; i < length; ++i) { + dst[i] = std::byte{static_cast((tag + i) & 0xFFU)}; + } + ring.CommitWrite(length); + ++sent; + } + }); + + std::uint32_t received = 0; + while (received < kTotal) { + const FrameView view = ring.BeginRead(); + if (view.Empty()) { std::this_thread::yield(); continue; } - // 长度与内容都由序号决定,消费者可独立校验。 - const auto length = static_cast(14 + (sent % 400)); - const auto tag = static_cast(sent & 0xFFU); - for (std::uint32_t i = 0; i < length; ++i) { - dst[i] = std::byte{static_cast((tag + i) & 0xFFU)}; + const auto expected_length = static_cast(14 + (received % 400)); + const auto expected_tag = static_cast(received & 0xFFU); + if (view.length != expected_length || !VerifyFrame(view.Bytes(), expected_tag)) { + corrupted.store(true, std::memory_order_relaxed); + ring.CommitRead(); + break; } - ring.CommitWrite(length); - ++sent; - } - }); - - std::uint32_t received = 0; - while (received < kTotal) { - const FrameView view = ring.BeginRead(); - if (view.Empty()) { - std::this_thread::yield(); - continue; - } - const auto expected_length = static_cast(14 + (received % 400)); - const auto expected_tag = static_cast(received & 0xFFU); - if (view.length != expected_length || !VerifyFrame(view.Bytes(), expected_tag)) { - corrupted.store(true, std::memory_order_relaxed); ring.CommitRead(); - break; + ++received; } - ring.CommitRead(); - ++received; - } - producer.join(); - CHECK_FALSE(corrupted.load()); - CHECK(received == kTotal); - CHECK(ring.TotalEnqueued() == kTotal); - CHECK(ring.TotalDequeued() == kTotal); -} + producer.join(); + CHECK_FALSE(corrupted.load()); + CHECK(received == kTotal); + CHECK(ring.TotalEnqueued() == kTotal); + CHECK(ring.TotalDequeued() == kTotal); + } } // TEST_SUITE("common.frame_ring") TEST_SUITE("common.frame_ring_batch") { + TEST_CASE("batch write: consumers see nothing while staged, then all at once after publish") { + FrameRing ring(16, 1514); + { + auto batch = ring.BeginBatchWrite(); + for (std::uint8_t i = 0; i < 5; ++i) { + REQUIRE(batch.Push(MakeFrame(64, i))); + } + CHECK(batch.Staged() == 5); + CHECK(batch.Published() == 0); + // Key semantics: not yet published, so the consumer cannot see a single frame. + CHECK(ring.SizeSnapshot() == 0); + CHECK(ring.BeginRead().Empty()); + } // destructor -> one PublishWrite(5) + CHECK(ring.SizeSnapshot() == 5); + } -TEST_CASE("批量写:暂存期间消费者看不到,发布后一次性可见") { - FrameRing ring(16, 1514); - { + TEST_CASE("batch write: can continue staging after an explicit Publish") { + FrameRing ring(16, 1514); auto batch = ring.BeginBatchWrite(); - for (std::uint8_t i = 0; i < 5; ++i) { - REQUIRE(batch.Push(MakeFrame(64, i))); - } - CHECK(batch.Staged() == 5); - CHECK(batch.Published() == 0); - // 关键语义:尚未发布,消费者一帧都看不到。 - CHECK(ring.SizeSnapshot() == 0); - CHECK(ring.BeginRead().Empty()); - } // 析构 → 一次 PublishWrite(5) - CHECK(ring.SizeSnapshot() == 5); -} - -TEST_CASE("批量写:显式 Publish 后可继续暂存") { - FrameRing ring(16, 1514); - auto batch = ring.BeginBatchWrite(); - REQUIRE(batch.Push(MakeFrame(64, 1))); - REQUIRE(batch.Push(MakeFrame(64, 2))); - batch.Publish(); - CHECK(batch.Published() == 2); - CHECK(batch.Staged() == 0); - CHECK(ring.SizeSnapshot() == 2); - - REQUIRE(batch.Push(MakeFrame(64, 3))); - CHECK(batch.Staged() == 1); - batch.Publish(); - CHECK(batch.Published() == 3); - CHECK(ring.SizeSnapshot() == 3); -} + REQUIRE(batch.Push(MakeFrame(64, 1))); + REQUIRE(batch.Push(MakeFrame(64, 2))); + batch.Publish(); + CHECK(batch.Published() == 2); + CHECK(batch.Staged() == 0); + CHECK(ring.SizeSnapshot() == 2); + + REQUIRE(batch.Push(MakeFrame(64, 3))); + CHECK(batch.Staged() == 1); + batch.Publish(); + CHECK(batch.Published() == 3); + CHECK(ring.SizeSnapshot() == 3); + } -TEST_CASE("批量写:队列满时 Begin 返回空,已暂存的仍会发布") { - FrameRing ring(4, 64); - { - auto batch = ring.BeginBatchWrite(); - std::uint32_t accepted = 0; - for (std::uint8_t i = 0; i < 10; ++i) { - if (!batch.Push(MakeFrame(64, i))) { - break; + TEST_CASE("batch write: Begin returns empty when full, but staged frames are still published") { + FrameRing ring(4, 64); + { + auto batch = ring.BeginBatchWrite(); + std::uint32_t accepted = 0; + for (std::uint8_t i = 0; i < 10; ++i) { + if (!batch.Push(MakeFrame(64, i))) { + break; + } + ++accepted; } - ++accepted; + CHECK(accepted == 4); // capacity 4, fully utilized + CHECK(batch.Staged() == 4); } - CHECK(accepted == 4); // 容量 4,能完整利用 - CHECK(batch.Staged() == 4); + CHECK(ring.SizeSnapshot() == 4); } - CHECK(ring.SizeSnapshot() == 4); -} -TEST_CASE("批量读:释放前视图有效,释放后槽位才可复用") { - FrameRing ring(8, 1514); - for (std::uint8_t i = 0; i < 4; ++i) { - REQUIRE(ring.TryPush(MakeFrame(100, static_cast(0x20 + i)))); + TEST_CASE("batch read: views are valid before release and slots are reusable only after") { + FrameRing ring(8, 1514); + for (std::uint8_t i = 0; i < 4; ++i) { + REQUIRE(ring.TryPush(MakeFrame(100, static_cast(0x20 + i)))); + } + + std::vector views; + { + auto batch = ring.BeginBatchRead(); + for (int i = 0; i < 4; ++i) { + const FrameView view = batch.Next(); + REQUIRE_FALSE(view.Empty()); + views.push_back(view); + } + CHECK(batch.Next().Empty()); // exhausted + CHECK(batch.Staged() == 4); + // Key semantics: not yet released, so all views still point to valid, un-overwritten + // content. This is the prerequisite for "accumulating a batch for a zero-copy bulk write". + for (std::size_t i = 0; i < views.size(); ++i) { + CHECK(views[i].length == 100); + CHECK(VerifyFrame(views[i].Bytes(), static_cast(0x20 + i))); + } + // Before release, the free slots visible to the producer must not include these 4. + CHECK(ring.SizeSnapshot() == 4); + } // destructor -> one PublishRead(4) + CHECK(ring.SizeSnapshot() == 0); } - std::vector views; - { + TEST_CASE("batch read: can continue reading after an explicit Release") { + FrameRing ring(8, 1514); + for (std::uint8_t i = 0; i < 6; ++i) { + REQUIRE(ring.TryPush(MakeFrame(64, i))); + } auto batch = ring.BeginBatchRead(); - for (int i = 0; i < 4; ++i) { - const FrameView view = batch.Next(); - REQUIRE_FALSE(view.Empty()); - views.push_back(view); + for (int i = 0; i < 3; ++i) { + REQUIRE_FALSE(batch.Next().Empty()); } - CHECK(batch.Next().Empty()); // 取完了 - CHECK(batch.Staged() == 4); - // 关键语义:尚未释放,所有视图仍指向有效且未被覆写的内容。 - // 这正是「攒一批零拷贝批量写出去」的前提。 - for (std::size_t i = 0; i < views.size(); ++i) { - CHECK(views[i].length == 100); - CHECK(VerifyFrame(views[i].Bytes(), static_cast(0x20 + i))); + batch.Release(); + CHECK(batch.Released() == 3); + CHECK(ring.SizeSnapshot() == 3); + for (int i = 0; i < 3; ++i) { + REQUIRE_FALSE(batch.Next().Empty()); } - // 未释放前生产者看到的空闲槽位不应包含这 4 个。 - CHECK(ring.SizeSnapshot() == 4); - } // 析构 → 一次 PublishRead(4) - CHECK(ring.SizeSnapshot() == 0); -} - -TEST_CASE("批量读:显式 Release 后可继续取") { - FrameRing ring(8, 1514); - for (std::uint8_t i = 0; i < 6; ++i) { - REQUIRE(ring.TryPush(MakeFrame(64, i))); - } - auto batch = ring.BeginBatchRead(); - for (int i = 0; i < 3; ++i) { - REQUIRE_FALSE(batch.Next().Empty()); - } - batch.Release(); - CHECK(batch.Released() == 3); - CHECK(ring.SizeSnapshot() == 3); - for (int i = 0; i < 3; ++i) { - REQUIRE_FALSE(batch.Next().Empty()); + batch.Release(); + CHECK(batch.Released() == 6); + CHECK(ring.SizeSnapshot() == 0); } - batch.Release(); - CHECK(batch.Released() == 6); - CHECK(ring.SizeSnapshot() == 0); -} -TEST_CASE("批量与逐帧接口混用仍保持 FIFO 与内容正确") { - FrameRing ring(16, 1514); - // 先逐帧推 2 个,再批量推 3 个,然后批量读 5 个。 - REQUIRE(ring.TryPush(MakeFrame(64, 0))); - REQUIRE(ring.TryPush(MakeFrame(64, 1))); - { - auto batch = ring.BeginBatchWrite(); - for (std::uint8_t i = 2; i < 5; ++i) { - REQUIRE(batch.Push(MakeFrame(64, i))); + TEST_CASE("mixing batch and per-frame interfaces still preserves FIFO and correct contents") { + FrameRing ring(16, 1514); + // Push 2 frames one by one, then 3 in a batch, then read 5 in a batch. + REQUIRE(ring.TryPush(MakeFrame(64, 0))); + REQUIRE(ring.TryPush(MakeFrame(64, 1))); + { + auto batch = ring.BeginBatchWrite(); + for (std::uint8_t i = 2; i < 5; ++i) { + REQUIRE(batch.Push(MakeFrame(64, i))); + } } + auto batch = ring.BeginBatchRead(); + for (std::uint8_t i = 0; i < 5; ++i) { + const FrameView view = batch.Next(); + REQUIRE_FALSE(view.Empty()); + CHECK(VerifyFrame(view.Bytes(), i)); + } + CHECK(batch.Next().Empty()); } - auto batch = ring.BeginBatchRead(); - for (std::uint8_t i = 0; i < 5; ++i) { - const FrameView view = batch.Next(); - REQUIRE_FALSE(view.Empty()); - CHECK(VerifyFrame(view.Bytes(), i)); - } - CHECK(batch.Next().Empty()); -} -TEST_CASE("批量接口并发搬运 20 万帧不丢不乱序") { - constexpr std::uint32_t kTotal = 200'000; - constexpr std::uint32_t kBatch = 32; - FrameRing ring(1024, 512); - std::atomic corrupted{false}; - - std::thread producer([&] { - std::uint32_t sent = 0; - while (sent < kTotal) { - auto batch = ring.BeginBatchWrite(); - for (std::uint32_t i = 0; i < kBatch && sent < kTotal; ++i) { - const std::span dst = batch.Begin(); - if (dst.empty()) { + TEST_CASE("batch interface concurrent transfer of 200,000 frames without loss or reordering") { + constexpr std::uint32_t kTotal = 200'000; + constexpr std::uint32_t kBatch = 32; + FrameRing ring(1024, 512); + std::atomic corrupted{false}; + + std::thread producer([&] { + std::uint32_t sent = 0; + while (sent < kTotal) { + auto batch = ring.BeginBatchWrite(); + for (std::uint32_t i = 0; i < kBatch && sent < kTotal; ++i) { + const std::span dst = batch.Begin(); + if (dst.empty()) { + break; + } + const auto length = static_cast(14 + (sent % 400)); + const auto tag = static_cast(sent & 0xFFU); + for (std::uint32_t k = 0; k < length; ++k) { + dst[k] = std::byte{static_cast((tag + k) & 0xFFU)}; + } + batch.Commit(length); + ++sent; + } + } + }); + + std::uint32_t received = 0; + while (received < kTotal) { + auto batch = ring.BeginBatchRead(); + for (std::uint32_t i = 0; i < kBatch && received < kTotal; ++i) { + const FrameView view = batch.Next(); + if (view.Empty()) { break; } - const auto length = static_cast(14 + (sent % 400)); - const auto tag = static_cast(sent & 0xFFU); - for (std::uint32_t k = 0; k < length; ++k) { - dst[k] = std::byte{static_cast((tag + k) & 0xFFU)}; + const auto expected_length = static_cast(14 + (received % 400)); + const auto expected_tag = static_cast(received & 0xFFU); + if (view.length != expected_length || !VerifyFrame(view.Bytes(), expected_tag)) { + corrupted.store(true, std::memory_order_relaxed); + received = kTotal; + break; } - batch.Commit(length); - ++sent; + ++received; } } - }); - std::uint32_t received = 0; - while (received < kTotal) { - auto batch = ring.BeginBatchRead(); - for (std::uint32_t i = 0; i < kBatch && received < kTotal; ++i) { - const FrameView view = batch.Next(); - if (view.Empty()) { - break; - } - const auto expected_length = static_cast(14 + (received % 400)); - const auto expected_tag = static_cast(received & 0xFFU); - if (view.length != expected_length || !VerifyFrame(view.Bytes(), expected_tag)) { - corrupted.store(true, std::memory_order_relaxed); - received = kTotal; - break; - } - ++received; - } + producer.join(); + CHECK_FALSE(corrupted.load()); + CHECK(ring.TotalDequeued() == ring.TotalEnqueued()); } - producer.join(); - CHECK_FALSE(corrupted.load()); - CHECK(ring.TotalDequeued() == ring.TotalEnqueued()); -} - } // TEST_SUITE("common.frame_ring_batch") diff --git a/tests/test_common_spsc_ring.cc b/tests/test_common_spsc_ring.cc index 77efaed..886d881 100644 --- a/tests/test_common_spsc_ring.cc +++ b/tests/test_common_spsc_ring.cc @@ -1,6 +1,6 @@ -// SpscRing / SpscCursor 的单元测试。 +// Unit tests for SpscRing / SpscCursor. // -// 多线程用例在 ThreadSanitizer 构建下才有完整意义: +// Multi-threaded cases only have full meaning under a ThreadSanitizer build: // cmake -B build-tsan -DTETHERKIT_ENABLE_TSAN=ON && ctest --test-dir build-tsan -R spsc #include #include @@ -16,7 +16,7 @@ using tetherkit::SpscRing; namespace { -/// 用于验证「元素内容确实完整传递」而非只传了索引。 +/// Used to verify that "element contents are transferred completely" rather than just indices. struct Payload { std::uint64_t sequence; std::uint32_t checksum; @@ -29,106 +29,110 @@ constexpr std::uint32_t Checksum(std::uint64_t sequence) { } // namespace TEST_SUITE("common.spsc_ring") { - -TEST_CASE("容量向上取整到 2 的幂") { - CHECK(SpscRing(1).Capacity() == 1); - CHECK(SpscRing(3).Capacity() == 4); - CHECK(SpscRing(4).Capacity() == 4); - CHECK(SpscRing(1000).Capacity() == 1024); -} - -TEST_CASE("空队列出队失败") { - SpscRing ring(4); - std::uint32_t value = 0xFFFFFFFFU; - CHECK_FALSE(ring.TryPop(value)); - CHECK(value == 0xFFFFFFFFU); // 失败时不得改动输出参数 - CHECK(ring.SizeSnapshot() == 0); -} - -TEST_CASE("满队列入队失败且容量被完整利用") { - // 自由递增计数器的设计目标之一:不浪费槽位。容量 4 就应能装 4 个。 - SpscRing ring(4); - for (std::uint32_t i = 0; i < 4; ++i) { - CHECK(ring.TryPush(i)); + TEST_CASE("capacity rounds up to a power of two") { + CHECK(SpscRing(1).Capacity() == 1); + CHECK(SpscRing(3).Capacity() == 4); + CHECK(SpscRing(4).Capacity() == 4); + CHECK(SpscRing(1000).Capacity() == 1024); } - CHECK(ring.SizeSnapshot() == 4); - CHECK_FALSE(ring.TryPush(99)); -} -TEST_CASE("FIFO 顺序与内容完整性") { - SpscRing ring(8); - for (std::uint64_t i = 0; i < 8; ++i) { - CHECK(ring.TryPush(Payload{.sequence = i, .checksum = Checksum(i)})); + TEST_CASE("dequeue from an empty queue fails") { + SpscRing ring(4); + std::uint32_t value = 0xFFFFFFFFU; + CHECK_FALSE(ring.TryPop(value)); + CHECK(value == 0xFFFFFFFFU); // the output parameter must not be modified on failure + CHECK(ring.SizeSnapshot() == 0); } - for (std::uint64_t i = 0; i < 8; ++i) { - Payload out{}; - REQUIRE(ring.TryPop(out)); - CHECK(out.sequence == i); - CHECK(out.checksum == Checksum(i)); - } - CHECK(ring.SizeSnapshot() == 0); -} -TEST_CASE("反复绕环不丢数据") { - // 容量 4,推 1000 个元素,每推一个立刻取一个,逼迫索引反复回绕。 - SpscRing ring(4); - for (std::uint64_t i = 0; i < 1000; ++i) { - REQUIRE(ring.TryPush(i)); - std::uint64_t out = 0; - REQUIRE(ring.TryPop(out)); - CHECK(out == i); + TEST_CASE("enqueue into a full queue fails and capacity is fully utilized") { + // One design goal of the freely incrementing counter: do not waste slots. A capacity of 4 + // should hold 4 elements. + SpscRing ring(4); + for (std::uint32_t i = 0; i < 4; ++i) { + CHECK(ring.TryPush(i)); + } + CHECK(ring.SizeSnapshot() == 4); + CHECK_FALSE(ring.TryPush(99)); } -} -TEST_CASE("索引游标各字段落在不同缓存行") { - // 这是性能正确性断言:若 false sharing 回归,本用例会失败。 - SpscRing ring(8); - const auto& cursor = ring.Cursor(); - const auto base = reinterpret_cast(&cursor); - // SpscCursor 的四个索引各自 alignas(kCacheLineSize), - // 因此整个对象至少要占 4 条缓存行(外加只读字段那一条)。 - CHECK(sizeof(tetherkit::SpscCursor) >= 4 * kCacheLineSize); - CHECK(base % kCacheLineSize == 0); -} + TEST_CASE("FIFO order and content integrity") { + SpscRing ring(8); + for (std::uint64_t i = 0; i < 8; ++i) { + CHECK(ring.TryPush(Payload{.sequence = i, .checksum = Checksum(i)})); + } + for (std::uint64_t i = 0; i < 8; ++i) { + Payload out{}; + REQUIRE(ring.TryPop(out)); + CHECK(out.sequence == i); + CHECK(out.checksum == Checksum(i)); + } + CHECK(ring.SizeSnapshot() == 0); + } -TEST_CASE("单生产者单消费者并发搬运不丢不重不乱序") { - constexpr std::uint64_t kTotal = 200'000; - SpscRing ring(1024); + TEST_CASE("repeated wrap-around does not lose data") { + // Capacity 4, push 1000 elements, pop each one immediately, forcing the indices to wrap + // around repeatedly. + SpscRing ring(4); + for (std::uint64_t i = 0; i < 1000; ++i) { + REQUIRE(ring.TryPush(i)); + std::uint64_t out = 0; + REQUIRE(ring.TryPop(out)); + CHECK(out == i); + } + } - std::atomic produced{0}; - std::atomic corrupted{false}; + TEST_CASE("index cursor fields land on different cache lines") { + // This is a performance-correctness assertion: if false sharing regresses, this case fails. + SpscRing ring(8); + const auto& cursor = ring.Cursor(); + const auto base = reinterpret_cast(&cursor); + // The four indices in SpscCursor are each alignas(kCacheLineSize), so the whole object + // must occupy at least 4 cache lines (plus the one for read-only fields). + CHECK(sizeof(tetherkit::SpscCursor) >= 4 * kCacheLineSize); + CHECK(base % kCacheLineSize == 0); + } - std::thread producer([&] { - std::uint64_t sequence = 0; - while (sequence < kTotal) { - if (ring.TryPush(Payload{.sequence = sequence, .checksum = Checksum(sequence)})) { - ++sequence; - produced.store(sequence, std::memory_order_relaxed); + TEST_CASE( + "single-producer single-consumer concurrent transfer without loss, duplication, or " + "reordering") { + constexpr std::uint64_t kTotal = 200'000; + SpscRing ring(1024); + + std::atomic produced{0}; + std::atomic corrupted{false}; + + std::thread producer([&] { + std::uint64_t sequence = 0; + while (sequence < kTotal) { + if (ring.TryPush(Payload{.sequence = sequence, .checksum = Checksum(sequence)})) { + ++sequence; + produced.store(sequence, std::memory_order_relaxed); + } else { + std::this_thread::yield(); + } + } + }); + + std::uint64_t expected = 0; + while (expected < kTotal) { + Payload out{}; + if (ring.TryPop(out)) { + // Order must be strictly increasing (FIFO) and content must be consistent with the + // sequence number (no tearing). + if (out.sequence != expected || out.checksum != Checksum(out.sequence)) { + corrupted.store(true, std::memory_order_relaxed); + break; + } + ++expected; } else { std::this_thread::yield(); } } - }); - - std::uint64_t expected = 0; - while (expected < kTotal) { - Payload out{}; - if (ring.TryPop(out)) { - // 顺序必须严格递增(FIFO),内容必须与序号自洽(无撕裂)。 - if (out.sequence != expected || out.checksum != Checksum(out.sequence)) { - corrupted.store(true, std::memory_order_relaxed); - break; - } - ++expected; - } else { - std::this_thread::yield(); - } - } - producer.join(); - CHECK_FALSE(corrupted.load()); - CHECK(expected == kTotal); - CHECK(ring.SizeSnapshot() == 0); -} + producer.join(); + CHECK_FALSE(corrupted.load()); + CHECK(expected == kTotal); + CHECK(ring.SizeSnapshot() == 0); + } } // TEST_SUITE("common.spsc_ring") diff --git a/tests/test_common_stats.cc b/tests/test_common_stats.cc index 26111d7..58ca0fa 100644 --- a/tests/test_common_stats.cc +++ b/tests/test_common_stats.cc @@ -1,4 +1,4 @@ -// stats.h 与 time.h 的单元测试。 +// Unit tests for stats.h and time.h. #include #include @@ -17,112 +17,115 @@ using tetherkit::Snapshot; using tetherkit::Stopwatch; TEST_SUITE("common.stats") { - -TEST_CASE("计数器累加与快照") { - DirectionCounters counters; - counters.AddFrame(1514); - counters.AddFrame(64); - counters.AddDroppedFull(3); - counters.AddDroppedOversize(); - counters.AddDroppedMalformed(2); - counters.AddIoError(); - - const auto snapshot = Snapshot(counters); - CHECK(snapshot.frames == 2); - CHECK(snapshot.bytes == 1578); - CHECK(snapshot.dropped_full == 3); - CHECK(snapshot.dropped_oversize == 1); - CHECK(snapshot.dropped_malformed == 2); - CHECK(snapshot.io_errors == 1); - CHECK(snapshot.TotalDropped() == 6); -} - -TEST_CASE("批次计数用于计算平均批大小") { - DirectionCounters counters; - counters.AddBatch(10, 15140); - counters.AddBatch(6, 9084); - - const auto snapshot = Snapshot(counters); - CHECK(snapshot.frames == 16); - CHECK(snapshot.bytes == 24224); - CHECK(snapshot.batches == 2); -} - -TEST_CASE("快照相减得到窗口增量") { - DirectionCounters counters; - counters.AddFrame(100); - const auto first = Snapshot(counters); - counters.AddFrame(200); - counters.AddDroppedFull(); - const auto second = Snapshot(counters); - - const auto delta = second - first; - CHECK(delta.frames == 1); - CHECK(delta.bytes == 200); - CHECK(delta.dropped_full == 1); -} - -TEST_CASE("速率采样把字节数换算成 Mbps") { - PathCounters counters; - RateSampler sampler; - - // 造 1 秒内 12.5 MB 的流量 = 100 Mbps。 - counters.rx.AddBatch(8000, 12'500'000); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); - const auto report = sampler.Sample(counters); - - CHECK(report.seconds > 0.0); - // 只跑了约 20ms,所以速率会被放大约 50 倍;这里只校验换算关系是否自洽。 - const double expected_mbps = 12'500'000.0 * 8.0 / 1'000'000.0 / report.seconds; - CHECK(report.rx_mbps == doctest::Approx(expected_mbps).epsilon(0.01)); - CHECK(report.rx_avg_batch == doctest::Approx(8000.0)); - CHECK(report.tx_pps == doctest::Approx(0.0)); -} - -TEST_CASE("秒表读数单调不减") { - const Stopwatch watch; - const auto first = watch.Elapsed(); - std::this_thread::sleep_for(std::chrono::milliseconds(2)); - const auto second = watch.Elapsed(); - CHECK(second >= first); - CHECK(watch.ElapsedMillis() >= 1.0); -} - -TEST_CASE("周期定时器的默认构造仍然是「构造即开始计时」") { - PeriodicTimer timer(50 * kNanosPerMilli); - // 刚构造完不该到期。 - CHECK_FALSE(timer.Expired()); - CHECK(timer.RemainingNanos(MonotonicNanos()) > 0); - CHECK(timer.Period() == 50 * kNanosPerMilli); -} - -TEST_CASE("周期定时器按累加期限推进,不随调度延迟漂移") { - const tetherkit::Nanos now = MonotonicNanos(); - // 必须把 `now` 作为计时起点显式传进去。若让构造函数自己读时钟,它读到的时刻 - // 会比这里的 `now` 略晚,于是「now + period」反而还没到期,断言就会随机失败。 - PeriodicTimer timer(10 * kNanosPerMilli, now); - - CHECK_FALSE(timer.Expired(now)); - CHECK(timer.RemainingNanos(now) > 0); - - // 恰好到期。 - CHECK(timer.Expired(now + 10 * kNanosPerMilli)); - // 刚触发过,立刻再问应为未到期。 - CHECK_FALSE(timer.Expired(now + 10 * kNanosPerMilli)); - // 再过一个周期又到期。 - CHECK(timer.Expired(now + 20 * kNanosPerMilli)); -} - -TEST_CASE("周期定时器落后超过一个周期时不补发一串触发") { - const tetherkit::Nanos now = MonotonicNanos(); - PeriodicTimer timer(10 * kNanosPerMilli, now); - - // 线程被挂起 1 秒后才醒来:应该只触发一次,然后对齐到当前时刻之后。 - const tetherkit::Nanos late = now + 1000 * kNanosPerMilli; - CHECK(timer.Expired(late)); - CHECK_FALSE(timer.Expired(late)); - CHECK_FALSE(timer.Expired(late + 9 * kNanosPerMilli)); - CHECK(timer.Expired(late + 10 * kNanosPerMilli)); -} + TEST_CASE("counter accumulation and snapshot") { + DirectionCounters counters; + counters.AddFrame(1514); + counters.AddFrame(64); + counters.AddDroppedFull(3); + counters.AddDroppedOversize(); + counters.AddDroppedMalformed(2); + counters.AddIoError(); + + const auto snapshot = Snapshot(counters); + CHECK(snapshot.frames == 2); + CHECK(snapshot.bytes == 1578); + CHECK(snapshot.dropped_full == 3); + CHECK(snapshot.dropped_oversize == 1); + CHECK(snapshot.dropped_malformed == 2); + CHECK(snapshot.io_errors == 1); + CHECK(snapshot.TotalDropped() == 6); + } + + TEST_CASE("batch count is used to compute average batch size") { + DirectionCounters counters; + counters.AddBatch(10, 15140); + counters.AddBatch(6, 9084); + + const auto snapshot = Snapshot(counters); + CHECK(snapshot.frames == 16); + CHECK(snapshot.bytes == 24224); + CHECK(snapshot.batches == 2); + } + + TEST_CASE("subtracting snapshots yields the window delta") { + DirectionCounters counters; + counters.AddFrame(100); + const auto first = Snapshot(counters); + counters.AddFrame(200); + counters.AddDroppedFull(); + const auto second = Snapshot(counters); + + const auto delta = second - first; + CHECK(delta.frames == 1); + CHECK(delta.bytes == 200); + CHECK(delta.dropped_full == 1); + } + + TEST_CASE("rate sampling converts byte counts to Mbps") { + PathCounters counters; + RateSampler sampler; + + // Create 12.5 MB of traffic over 1 second = 100 Mbps. + counters.rx.AddBatch(8000, 12'500'000); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + const auto report = sampler.Sample(counters); + + CHECK(report.seconds > 0.0); + // Only about 20 ms elapsed, so the rate is amplified ~50x; only verify the conversion is + // self-consistent here. + const double expected_mbps = 12'500'000.0 * 8.0 / 1'000'000.0 / report.seconds; + CHECK(report.rx_mbps == doctest::Approx(expected_mbps).epsilon(0.01)); + CHECK(report.rx_avg_batch == doctest::Approx(8000.0)); + CHECK(report.tx_pps == doctest::Approx(0.0)); + } + + TEST_CASE("stopwatch readings are monotonically non-decreasing") { + const Stopwatch watch; + const auto first = watch.Elapsed(); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + const auto second = watch.Elapsed(); + CHECK(second >= first); + CHECK(watch.ElapsedMillis() >= 1.0); + } + + TEST_CASE("PeriodicTimer default construction still means timing starts at construction") { + PeriodicTimer timer(50 * kNanosPerMilli); + // It should not be expired immediately after construction. + CHECK_FALSE(timer.Expired()); + CHECK(timer.RemainingNanos(MonotonicNanos()) > 0); + CHECK(timer.Period() == 50 * kNanosPerMilli); + } + + TEST_CASE( + "PeriodicTimer advances by accumulated deadlines without drifting on scheduling delay") { + const tetherkit::Nanos now = MonotonicNanos(); + // `now` must be passed explicitly as the timing origin. If the constructor reads the clock + // itself, the time it reads will be slightly later than `now` here, so "now + period" would + // not yet be due and the assertion would fail randomly. + PeriodicTimer timer(10 * kNanosPerMilli, now); + + CHECK_FALSE(timer.Expired(now)); + CHECK(timer.RemainingNanos(now) > 0); + + // Exactly due. + CHECK(timer.Expired(now + 10 * kNanosPerMilli)); + // Just fired, so asking immediately should report not expired. + CHECK_FALSE(timer.Expired(now + 10 * kNanosPerMilli)); + // Due again after one more period. + CHECK(timer.Expired(now + 20 * kNanosPerMilli)); + } + + TEST_CASE("PeriodicTimer does not emit a burst of triggers when more than one period behind") { + const tetherkit::Nanos now = MonotonicNanos(); + PeriodicTimer timer(10 * kNanosPerMilli, now); + + // The thread wakes up 1 second later after being suspended: it should fire only once, then + // align to after the current time. + const tetherkit::Nanos late = now + 1000 * kNanosPerMilli; + CHECK(timer.Expired(late)); + CHECK_FALSE(timer.Expired(late)); + CHECK_FALSE(timer.Expired(late + 9 * kNanosPerMilli)); + CHECK(timer.Expired(late + 10 * kNanosPerMilli)); + } } // TEST_SUITE("common.stats") diff --git a/tests/test_core_bridge.cc b/tests/test_core_bridge.cc index c05b46f..35ee0fa 100644 --- a/tests/test_core_bridge.cc +++ b/tests/test_core_bridge.cc @@ -1,9 +1,10 @@ -// 桥接层的端到端测试。 +// End-to-end tests for the bridge layer. // -// 用 MockDataChannel(USB 侧)+ LoopbackLink(网卡侧)把完整的数据路径跑起来, -// 覆盖真实的多线程搬运、批处理、背压、暂停与优雅停机。 +// MockDataChannel (the USB side) + LoopbackLink (the interface side) run the +// complete data path, covering the real multi-threaded movement, batching, +// backpressure, pausing and graceful shutdown. // -// 这些用例在 ThreadSanitizer 下跑才有完整意义: +// These cases only make full sense under ThreadSanitizer: // cmake -B build-tsan -DTETHERKIT_ENABLE_TSAN=ON && ctest --test-dir build-tsan -R core #include #include @@ -28,11 +29,11 @@ using tetherkit::testing::MockDataChannel; namespace { -/// 造一帧内容可自校验的以太帧。 +/// Builds an Ethernet frame whose content can be self-verified. std::vector MakeFrame(std::uint32_t length, std::uint8_t tag) { std::vector frame(length); for (int i = 0; i < 6; ++i) { - frame[static_cast(i)] = std::byte{0xFF}; // 广播目的 MAC + frame[static_cast(i)] = std::byte{0xFF}; // broadcast destination MAC } frame[6] = std::byte{0x02}; frame[11] = std::byte{tag}; @@ -53,10 +54,11 @@ std::vector> MakeFrames(std::uint32_t count, std::uint32_ return frames; } -/// 轮询等待某个条件成立,最多等 `timeout`。 +/// Polls until a condition holds, waiting at most `timeout`. /// -/// 数据路径是异步的,不能用 sleep 一个固定时长来「等它跑完」—— 那样在慢机器上 -/// 会偶发失败。这里等条件而不是等时间。 +/// The data path is asynchronous, so sleeping for a fixed duration to "wait +/// for it to finish" is not an option -- that fails sporadically on slow +/// machines. Wait for the condition, not for the time. template bool WaitFor(Predicate predicate, std::chrono::milliseconds timeout = std::chrono::seconds(5)) { const auto deadline = std::chrono::steady_clock::now() + timeout; @@ -69,13 +71,14 @@ bool WaitFor(Predicate predicate, std::chrono::milliseconds timeout = std::chron return predicate(); } -/// 测试用的桥接配置:小队列、小批次,让边界更容易被触发。 +/// Bridge configuration for the tests: small queue, small batches, so the +/// boundaries are hit more easily. [[nodiscard]] BridgeConfig TestConfig() { BridgeConfig config; config.rx_ring_frames = 256; config.max_frame_bytes = 1518; config.rx_write_batch = 16; - config.rx_spin_before_yield = 4; // 让测试里的空转快点让出 CPU + config.rx_spin_before_yield = 4; // let the idle spin yield the CPU faster in tests config.tx_submit_batch = 32; return config; } @@ -83,400 +86,427 @@ bool WaitFor(Predicate predicate, std::chrono::milliseconds timeout = std::chron } // namespace TEST_SUITE("core.bridge") { + TEST_CASE("Start and stop: the threads come up and go down cleanly, the channel is closed") { + MockDataChannel channel; + LoopbackLink link; + Bridge bridge(channel, link, TestConfig()); -TEST_CASE("启动与停止:线程正常起停,停机时通道被关闭") { - MockDataChannel channel; - LoopbackLink link; - Bridge bridge(channel, link, TestConfig()); - - CHECK_FALSE(bridge.Running()); - REQUIRE(bridge.Start().has_value()); - CHECK(bridge.Running()); - CHECK(channel.Receiving()); + CHECK_FALSE(bridge.Running()); + REQUIRE(bridge.Start().has_value()); + CHECK(bridge.Running()); + CHECK(channel.Receiving()); - // 重复 Start 应被拒绝。 - CHECK_FALSE(bridge.Start().has_value()); - - bridge.Stop(); - CHECK_FALSE(bridge.Running()); - CHECK(channel.ShutdownCalled()); + // A repeated Start must be rejected. + CHECK_FALSE(bridge.Start().has_value()); - // Stop 是幂等的。 - bridge.Stop(); -} + bridge.Stop(); + CHECK_FALSE(bridge.Running()); + CHECK(channel.ShutdownCalled()); -TEST_CASE("RX 方向:设备发来的帧被搬到链路上,内容完整") { - MockDataChannel channel; - LoopbackLink link(LoopbackConfig{.sent_capacity = 4096}); - Bridge bridge(channel, link, TestConfig()); - REQUIRE(bridge.Start().has_value()); + // Stop is idempotent. + bridge.Stop(); + } - constexpr std::uint32_t kCount = 100; - const auto frames = MakeFrames(kCount, 1514); - CHECK(channel.InjectFromDevice(frames) == kCount); + TEST_CASE("RX direction: frames from the device are carried onto the link intact") { + MockDataChannel channel; + LoopbackLink link(LoopbackConfig{.sent_capacity = 4096}); + Bridge bridge(channel, link, TestConfig()); + REQUIRE(bridge.Start().has_value()); - REQUIRE(WaitFor([&] { return link.TotalSentFrames() >= kCount; })); + constexpr std::uint32_t kCount = 100; + const auto frames = MakeFrames(kCount, 1514); + CHECK(channel.InjectFromDevice(frames) == kCount); - const auto received = link.DrainSent(); - REQUIRE(received.size() == kCount); - // FIFO 顺序与内容都必须保持。 - for (std::size_t i = 0; i < kCount; ++i) { - CHECK(received[i] == frames[i]); - } + REQUIRE(WaitFor([&] { return link.TotalSentFrames() >= kCount; })); - const BridgeStats stats = bridge.Snapshot(); - CHECK(stats.rx.frames == kCount); - CHECK(stats.rx.bytes == static_cast(kCount) * 1514); + const auto received = link.DrainSent(); + REQUIRE(received.size() == kCount); + // Both the FIFO order and the content must be preserved. + for (std::size_t i = 0; i < kCount; ++i) { + CHECK(received[i] == frames[i]); + } - bridge.Stop(); -} + const BridgeStats stats = bridge.Snapshot(); + CHECK(stats.rx.frames == kCount); + CHECK(stats.rx.bytes == static_cast(kCount) * 1514); -TEST_CASE("TX 方向:链路收到的帧被提交给设备,内容完整") { - MockDataChannel channel; - LoopbackLink link(LoopbackConfig{.inbound_capacity = 4096, .max_frames_per_batch = 64}); - Bridge bridge(channel, link, TestConfig()); - REQUIRE(bridge.Start().has_value()); - - constexpr std::uint32_t kCount = 100; - const auto frames = MakeFrames(kCount, 512); - for (const std::vector& frame : frames) { - REQUIRE(link.PushInbound(frame)); + bridge.Stop(); } - REQUIRE(WaitFor([&] { return channel.SentFrameCount() >= kCount; })); + TEST_CASE("TX direction: frames received on the link are submitted to the device intact") { + MockDataChannel channel; + LoopbackLink link(LoopbackConfig{.inbound_capacity = 4096, .max_frames_per_batch = 64}); + Bridge bridge(channel, link, TestConfig()); + REQUIRE(bridge.Start().has_value()); - const auto delivered = channel.DrainSentToDevice(); - REQUIRE(delivered.size() == kCount); - for (std::size_t i = 0; i < kCount; ++i) { - CHECK(delivered[i] == frames[i]); - } + constexpr std::uint32_t kCount = 100; + const auto frames = MakeFrames(kCount, 512); + for (const std::vector& frame : frames) { + REQUIRE(link.PushInbound(frame)); + } - const BridgeStats stats = bridge.Snapshot(); - CHECK(stats.tx.frames == kCount); + REQUIRE(WaitFor([&] { return channel.SentFrameCount() >= kCount; })); - bridge.Stop(); -} + const auto delivered = channel.DrainSentToDevice(); + REQUIRE(delivered.size() == kCount); + for (std::size_t i = 0; i < kCount; ++i) { + CHECK(delivered[i] == frames[i]); + } -TEST_CASE("双向同时跑,互不干扰") { - MockDataChannel channel; - LoopbackLink link(LoopbackConfig{.inbound_capacity = 8192, .sent_capacity = 8192, - .max_frames_per_batch = 64}); - Bridge bridge(channel, link, TestConfig()); - REQUIRE(bridge.Start().has_value()); + const BridgeStats stats = bridge.Snapshot(); + CHECK(stats.tx.frames == kCount); - constexpr std::uint32_t kCount = 500; - const auto tx_frames = MakeFrames(kCount, 700); + bridge.Stop(); + } - // TX 侧:主机往设备发。 - std::thread pusher([&] { - for (const std::vector& frame : tx_frames) { - while (!link.PushInbound(frame)) { + TEST_CASE("Both directions run at once without interfering with each other") { + MockDataChannel channel; + LoopbackLink link(LoopbackConfig{ + .inbound_capacity = 8192, .sent_capacity = 8192, .max_frames_per_batch = 64}); + Bridge bridge(channel, link, TestConfig()); + REQUIRE(bridge.Start().has_value()); + + constexpr std::uint32_t kCount = 500; + const auto tx_frames = MakeFrames(kCount, 700); + + // TX side: the host sends to the device. + std::thread pusher([&] { + for (const std::vector& frame : tx_frames) { + while (!link.PushInbound(frame)) { + std::this_thread::yield(); + } + } + }); + + // RX side: the device sends to the host. Inject in batches, retry when the + // queue is full. + std::uint32_t injected = 0; + while (injected < kCount) { + const auto chunk = MakeFrames(std::min(32, kCount - injected), 1000); + const std::uint32_t accepted = channel.InjectFromDevice(chunk); + injected += accepted; + if (accepted == 0) { std::this_thread::yield(); } } - }); + pusher.join(); - // RX 侧:设备往主机发。分批注入,队列满时重试。 - std::uint32_t injected = 0; - while (injected < kCount) { - const auto chunk = MakeFrames(std::min(32, kCount - injected), 1000); - const std::uint32_t accepted = channel.InjectFromDevice(chunk); - injected += accepted; - if (accepted == 0) { - std::this_thread::yield(); - } - } - pusher.join(); - - REQUIRE(WaitFor([&] { return channel.SentFrameCount() >= kCount; })); - REQUIRE(WaitFor([&] { return link.TotalSentFrames() >= kCount; })); + REQUIRE(WaitFor([&] { return channel.SentFrameCount() >= kCount; })); + REQUIRE(WaitFor([&] { return link.TotalSentFrames() >= kCount; })); - const BridgeStats stats = bridge.Snapshot(); - CHECK(stats.rx.frames >= kCount); - CHECK(stats.tx.frames >= kCount); + const BridgeStats stats = bridge.Snapshot(); + CHECK(stats.rx.frames >= kCount); + CHECK(stats.tx.frames >= kCount); - bridge.Stop(); -} - -TEST_CASE("RX 队列满时丢弃并计数,不阻塞生产者") { - MockDataChannel channel; - // 链路的写出容量设很小,让 RX 注入线程写不出去,队列很快堆满。 - LoopbackLink link(LoopbackConfig{.sent_capacity = 8}); - auto config = TestConfig(); - config.rx_ring_frames = 16; - Bridge bridge(channel, link, config); - REQUIRE(bridge.Start().has_value()); - - // 注入远超队列容量的帧。 - std::uint32_t total_accepted = 0; - for (int round = 0; round < 20; ++round) { - total_accepted += channel.InjectFromDevice(MakeFrames(64, 1514)); + bridge.Stop(); } - // 关键断言:InjectFromDevice 从不阻塞,而且被拒的帧被计入丢弃。 - const BridgeStats stats = bridge.Snapshot(); - CHECK(total_accepted < 20 * 64); // 确实发生了拒收 - CHECK(stats.rx.TotalDropped() > 0); - - bridge.Stop(); -} - -TEST_CASE("TX 背压:传输池占满时等待而不丢弃,容量恢复后全部送达") { - // 这是真机丢帧根因的回归测试:旧实现在 SendFrames 返回 0 时立即丢弃批次 - // 剩余帧(bridge.cc 曾有一条「等待会变成不可见的内核丢包」的错误论证), - // 高负载下把 30%+ 的 TX 流量丢在了传输池门口。正确行为是等槽位空出来再重 - // 试 —— 突发由上游的内核 BPF 缓冲吸收。 - MockDataChannel channel; - LoopbackLink link(LoopbackConfig{.inbound_capacity = 4096, .max_frames_per_batch = 64}); - Bridge bridge(channel, link, TestConfig()); + TEST_CASE("When the RX queue is full frames are dropped and counted, the producer never blocks") { + MockDataChannel channel; + // The link's write capacity is tiny, so the RX injection thread cannot + // write out and the queue fills up quickly. + LoopbackLink link(LoopbackConfig{.sent_capacity = 8}); + auto config = TestConfig(); + config.rx_ring_frames = 16; + Bridge bridge(channel, link, config); + REQUIRE(bridge.Start().has_value()); + + // Inject far more frames than the queue can hold. + std::uint32_t total_accepted = 0; + for (int round = 0; round < 20; ++round) { + total_accepted += channel.InjectFromDevice(MakeFrames(64, 1514)); + } - // 让 mock 通道一帧都不收 —— 模拟传输池完全占满。 - channel.SetAcceptLimit(0); - REQUIRE(bridge.Start().has_value()); + // The key assertion: InjectFromDevice never blocks, and the rejected frames + // are counted as drops. + const BridgeStats stats = bridge.Snapshot(); + CHECK(total_accepted < 20 * 64); // rejections really happened + CHECK(stats.rx.TotalDropped() > 0); - constexpr std::uint32_t kFrameCount = 200; - for (const std::vector& frame : MakeFrames(kFrameCount, 512)) { - REQUIRE(link.PushInbound(frame)); + bridge.Stop(); } - // 桥接层应转入等待:背压事件在涨、WaitForSendCapacity 被调用, - // 但一帧都没有丢、也一帧都没有发出。 - REQUIRE(WaitFor([&] { return bridge.Snapshot().tx_backpressure_events > 0; })); - REQUIRE(WaitFor([&] { return channel.WaitCalls() > 0; })); - CHECK(bridge.Snapshot().tx.TotalDropped() == 0); - CHECK(channel.SentFrameCount() == 0); - - // 容量恢复(相当于在飞传输完成、槽位归还):先前顶在门口的帧必须 - // **一帧不少**地送达,且顺序不变。 - // 等待条件必须同时覆盖 mock 计数与桥接层计数:mock 在 SendFrames 内部先递增、 - // 桥接层在返回后才 AddBatch,两者之间没有 happens-before —— 只等 mock 计数就 - // 断言桥接层计数,会在慢机器上偶发读到差一个分片的旧值。 - channel.SetAcceptLimit(0xFFFF'FFFFU); - REQUIRE(WaitFor([&] { - return channel.SentFrameCount() >= kFrameCount && - bridge.Snapshot().tx.frames >= kFrameCount; - })); - - const BridgeStats stats = bridge.Snapshot(); - CHECK(stats.tx.TotalDropped() == 0); - CHECK(stats.tx.frames == kFrameCount); - CHECK(channel.SentFrameCount() == kFrameCount); - - const auto delivered = channel.DrainSentToDevice(); - REQUIRE(delivered.size() == kFrameCount); - const auto expected = MakeFrames(kFrameCount, 512); - for (std::size_t i = 0; i < delivered.size(); ++i) { - CHECK(delivered[i] == expected[i]); - } + TEST_CASE("TX backpressure: wait for capacity instead of dropping, then deliver every frame") { + // This is the regression test for the root cause of the real-device frame + // drops: the old implementation dropped the remaining frames of a batch the + // moment SendFrames returned 0 (bridge.cc carried a wrong argument that + // "waiting turns into invisible kernel drops"), and under heavy load it + // threw away more than 30% of the TX traffic at the transfer-pool door. + // The correct behavior is to wait for a slot to free up and retry -- + // bursts are absorbed by the upstream kernel BPF buffer. + MockDataChannel channel; + LoopbackLink link(LoopbackConfig{.inbound_capacity = 4096, .max_frames_per_batch = 64}); + Bridge bridge(channel, link, TestConfig()); + + // Make the mock channel accept not a single frame -- emulates a fully + // exhausted transfer pool. + channel.SetAcceptLimit(0); + REQUIRE(bridge.Start().has_value()); + + constexpr std::uint32_t kFrameCount = 200; + for (const std::vector& frame : MakeFrames(kFrameCount, 512)) { + REQUIRE(link.PushInbound(frame)); + } - bridge.Stop(); -} + // The bridge must fall into waiting: the backpressure event count climbs + // and WaitForSendCapacity is called, but not one frame is dropped and not + // one frame is sent. + REQUIRE(WaitFor([&] { return bridge.Snapshot().tx_backpressure_events > 0; })); + REQUIRE(WaitFor([&] { return channel.WaitCalls() > 0; })); + CHECK(bridge.Snapshot().tx.TotalDropped() == 0); + CHECK(channel.SentFrameCount() == 0); + + // Capacity returns (as if an in-flight transfer completed and its slot came + // back): the frames queued at the door must be delivered **without losing a + // single one**, and in the same order. + // The wait condition must cover the mock counter and the bridge counter + // together: the mock increments inside SendFrames while the bridge adds the + // batch only after SendFrames returns, with no happens-before between them + // -- waiting on the mock counter alone and then asserting on the bridge + // counter sporadically reads a stale value one batch behind on slow + // machines. + channel.SetAcceptLimit(0xFFFF'FFFFU); + REQUIRE(WaitFor([&] { + return channel.SentFrameCount() >= kFrameCount && bridge.Snapshot().tx.frames >= kFrameCount; + })); + + const BridgeStats stats = bridge.Snapshot(); + CHECK(stats.tx.TotalDropped() == 0); + CHECK(stats.tx.frames == kFrameCount); + CHECK(channel.SentFrameCount() == kFrameCount); + + const auto delivered = channel.DrainSentToDevice(); + REQUIRE(delivered.size() == kFrameCount); + const auto expected = MakeFrames(kFrameCount, 512); + for (std::size_t i = 0; i < delivered.size(); ++i) { + CHECK(delivered[i] == expected[i]); + } -TEST_CASE("TX 背压:部分接纳时剩余帧重试送达,不丢不乱序") { - // 池没满但一次装不下整个分片(SendFrames 部分接纳):剩余帧必须原地重试。 - // 每次最多吃 7 帧、分片 32 帧,逼出「多次部分接纳拼完一个分片」的路径。 - MockDataChannel channel; - LoopbackLink link(LoopbackConfig{.inbound_capacity = 4096, .max_frames_per_batch = 64}); - Bridge bridge(channel, link, TestConfig()); + bridge.Stop(); + } - channel.SetAcceptLimit(7); - REQUIRE(bridge.Start().has_value()); + TEST_CASE("TX backpressure: on partial acceptance the rest is retried, none lost or reordered") { + // The pool is not full but cannot take a whole batch at once (SendFrames + // accepts partially): the remaining frames must be retried in place. + // At most 7 frames are accepted per call with a batch of 32 frames, which + // forces the "several partial acceptances make up one batch" path. + MockDataChannel channel; + LoopbackLink link(LoopbackConfig{.inbound_capacity = 4096, .max_frames_per_batch = 64}); + Bridge bridge(channel, link, TestConfig()); + + channel.SetAcceptLimit(7); + REQUIRE(bridge.Start().has_value()); + + constexpr std::uint32_t kFrameCount = 100; + for (const std::vector& frame : MakeFrames(kFrameCount, 512)) { + REQUIRE(link.PushInbound(frame)); + } - constexpr std::uint32_t kFrameCount = 100; - for (const std::vector& frame : MakeFrames(kFrameCount, 512)) { - REQUIRE(link.PushInbound(frame)); - } + REQUIRE(WaitFor([&] { return channel.SentFrameCount() >= kFrameCount; })); + CHECK(bridge.Snapshot().tx.TotalDropped() == 0); - REQUIRE(WaitFor([&] { return channel.SentFrameCount() >= kFrameCount; })); - CHECK(bridge.Snapshot().tx.TotalDropped() == 0); + const auto delivered = channel.DrainSentToDevice(); + REQUIRE(delivered.size() == kFrameCount); + const auto expected = MakeFrames(kFrameCount, 512); + for (std::size_t i = 0; i < delivered.size(); ++i) { + CHECK(delivered[i] == expected[i]); + } - const auto delivered = channel.DrainSentToDevice(); - REQUIRE(delivered.size() == kFrameCount); - const auto expected = MakeFrames(kFrameCount, 512); - for (std::size_t i = 0; i < delivered.size(); ++i) { - CHECK(delivered[i] == expected[i]); + bridge.Stop(); } - bridge.Stop(); -} + TEST_CASE("TX backpressure: Stop() returns promptly while a wait is in progress") { + // The backpressure wait introduced a new blocking point, and the shutdown + // must not be wedged by it: the TX thread rechecks the shutdown flag every + // time it wakes up (at most about 50 ms). + MockDataChannel channel; + LoopbackLink link(LoopbackConfig{.inbound_capacity = 4096, .max_frames_per_batch = 64}); + Bridge bridge(channel, link, TestConfig()); -TEST_CASE("TX 背压:等待期间 Stop() 能及时返回") { - // 背压等待引入了新的阻塞点,停机不能被它卡住:TX 线程每次醒来(至多约 - // 50 ms)都要重查停机标志。 - MockDataChannel channel; - LoopbackLink link(LoopbackConfig{.inbound_capacity = 4096, .max_frames_per_batch = 64}); - Bridge bridge(channel, link, TestConfig()); + channel.SetAcceptLimit(0); + REQUIRE(bridge.Start().has_value()); - channel.SetAcceptLimit(0); - REQUIRE(bridge.Start().has_value()); - - for (const std::vector& frame : MakeFrames(200, 512)) { - REQUIRE(link.PushInbound(frame)); + for (const std::vector& frame : MakeFrames(200, 512)) { + REQUIRE(link.PushInbound(frame)); + } + REQUIRE(WaitFor([&] { return bridge.Snapshot().tx_backpressure_events > 0; })); + + const auto stop_begin = std::chrono::steady_clock::now(); + bridge.Stop(); + const auto stop_elapsed = std::chrono::steady_clock::now() - stop_begin; + + CHECK_FALSE(bridge.Running()); + // The upper bound is "wait timeout + BPF read timeout + margin". The real + // constraint is on the order of tens of milliseconds; it is relaxed to 2 + // seconds here only so slow CI machines do not flake. + CHECK(stop_elapsed < std::chrono::seconds(2)); + // The frames not sent by the time of the shutdown count as drops -- the + // books must balance, nothing may vanish into thin air. + CHECK(bridge.Snapshot().tx.TotalDropped() > 0); } - REQUIRE(WaitFor([&] { return bridge.Snapshot().tx_backpressure_events > 0; })); - const auto stop_begin = std::chrono::steady_clock::now(); - bridge.Stop(); - const auto stop_elapsed = std::chrono::steady_clock::now() - stop_begin; + TEST_CASE("TX backpressure: a pause lands during the wait, the rest is dropped, thread alive") { + // A pause (RNDIS soft reset) takes precedence over the wait: the device + // drops unfinished packets anyway, so hoarding them is pointless. + MockDataChannel channel; + LoopbackLink link(LoopbackConfig{.inbound_capacity = 4096, .max_frames_per_batch = 64}); + Bridge bridge(channel, link, TestConfig()); - CHECK_FALSE(bridge.Running()); - // 上界取「等待超时 + BPF 读超时 + 富余」。真实约束是几十毫秒量级, - // 这里放宽到 2 秒只为不在慢 CI 上闪断。 - CHECK(stop_elapsed < std::chrono::seconds(2)); - // 停机时未送出的帧计入丢弃 —— 账要能对上,不能凭空消失。 - CHECK(bridge.Snapshot().tx.TotalDropped() > 0); -} + channel.SetAcceptLimit(0); + REQUIRE(bridge.Start().has_value()); -TEST_CASE("TX 背压:等待期间落下暂停,剩余帧被丢弃且线程存活") { - // 暂停(RNDIS 软复位)优先于等待:设备反正会丢弃未完成的包,攒着没有意义。 - MockDataChannel channel; - LoopbackLink link(LoopbackConfig{.inbound_capacity = 4096, .max_frames_per_batch = 64}); - Bridge bridge(channel, link, TestConfig()); + for (const std::vector& frame : MakeFrames(100, 512)) { + REQUIRE(link.PushInbound(frame)); + } + REQUIRE(WaitFor([&] { return bridge.Snapshot().tx_backpressure_events > 0; })); + CHECK(bridge.Snapshot().tx.TotalDropped() == 0); - channel.SetAcceptLimit(0); - REQUIRE(bridge.Start().has_value()); + bridge.SetPaused(true); + REQUIRE(WaitFor([&] { return bridge.Snapshot().tx.TotalDropped() > 0; })); + CHECK(channel.SentFrameCount() == 0); - for (const std::vector& frame : MakeFrames(100, 512)) { - REQUIRE(link.PushInbound(frame)); - } - REQUIRE(WaitFor([&] { return bridge.Snapshot().tx_backpressure_events > 0; })); - CHECK(bridge.Snapshot().tx.TotalDropped() == 0); - - bridge.SetPaused(true); - REQUIRE(WaitFor([&] { return bridge.Snapshot().tx.TotalDropped() > 0; })); - CHECK(channel.SentFrameCount() == 0); - - // 恢复后新的帧照常送达 —— 线程没有卡死在等待里。 - bridge.SetPaused(false); - channel.SetAcceptLimit(0xFFFF'FFFFU); - for (const std::vector& frame : MakeFrames(20, 512)) { - REQUIRE(link.PushInbound(frame)); + // After resuming, new frames are delivered as usual -- the thread did not + // wedge inside the wait. + bridge.SetPaused(false); + channel.SetAcceptLimit(0xFFFF'FFFFU); + for (const std::vector& frame : MakeFrames(20, 512)) { + REQUIRE(link.PushInbound(frame)); + } + REQUIRE(WaitFor([&] { return channel.SentFrameCount() >= 20; })); + + bridge.Stop(); } - REQUIRE(WaitFor([&] { return channel.SentFrameCount() >= 20; })); - bridge.Stop(); -} + TEST_CASE("Nothing moves while paused; after resuming the RX queue keeps being sent") { + MockDataChannel channel; + LoopbackLink link(LoopbackConfig{.sent_capacity = 4096}); + Bridge bridge(channel, link, TestConfig()); + REQUIRE(bridge.Start().has_value()); -TEST_CASE("暂停期间不搬运,恢复后 RX 队列里的帧继续送出") { - MockDataChannel channel; - LoopbackLink link(LoopbackConfig{.sent_capacity = 4096}); - Bridge bridge(channel, link, TestConfig()); - REQUIRE(bridge.Start().has_value()); + bridge.SetPaused(true); + CHECK(bridge.Paused()); - bridge.SetPaused(true); - CHECK(bridge.Paused()); + constexpr std::uint32_t kCount = 32; + CHECK(channel.InjectFromDevice(MakeFrames(kCount, 256)) == kCount); - constexpr std::uint32_t kCount = 32; - CHECK(channel.InjectFromDevice(MakeFrames(kCount, 256)) == kCount); + // No frame may be written to the link while paused. Give the injection + // thread a moment to prove it really did not move. + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + CHECK(link.TotalSentFrames() == 0); + // The frames are still in the queue, not dropped. + CHECK(bridge.Snapshot().rx_queue_depth == kCount); - // 暂停期间不该有帧被写到链路上。给注入线程一点时间证明它确实没动。 - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - CHECK(link.TotalSentFrames() == 0); - // 帧还在队列里,没被丢弃。 - CHECK(bridge.Snapshot().rx_queue_depth == kCount); + // After resuming everything must be sent -- a pause does not drop frames. + bridge.SetPaused(false); + REQUIRE(WaitFor([&] { return link.TotalSentFrames() >= kCount; })); + CHECK(bridge.Snapshot().rx.TotalDropped() == 0); - // 恢复后应全部送出 —— 暂停不丢帧。 - bridge.SetPaused(false); - REQUIRE(WaitFor([&] { return link.TotalSentFrames() >= kCount; })); - CHECK(bridge.Snapshot().rx.TotalDropped() == 0); + bridge.Stop(); + } - bridge.Stop(); -} + TEST_CASE("SetPaused never deadlocks in any lifecycle phase") { + // SetPaused(true) waits for the RX injection thread to confirm that it has + // stopped. That wait carries two deadlock risks, both far more serious + // than the frame-loss bug it fixes (what it blocks is the control path: + // link state changes, device soft resets): + // * nobody sets the confirmation bit when the thread is not running at + // all; + // * a stale confirmation bit makes the wait logic itself wrong. + // All three phases are walked through here; a deadlock anywhere shows up + // as a timeout of this case rather than as a silent pass. + MockDataChannel channel; + LoopbackLink link(LoopbackConfig{.sent_capacity = 4096}); + Bridge bridge(channel, link, TestConfig()); + + // Phase one: before Start(). + bridge.SetPaused(true); + CHECK(bridge.Paused()); + bridge.SetPaused(false); -TEST_CASE("SetPaused 在任何生命周期阶段都不会挂死") { - // SetPaused(true) 会等 RX 注入线程确认它停住了。这个等待有两种挂死风险, - // 都比它要修的漏帧问题严重得多(挂的是控制路径:链路状态变化、设备软复位): - // * 线程压根没在跑时没人来置确认位; - // * 确认位残留导致等待逻辑本身出错。 - // 这里把三个阶段都走一遍,任何一处挂死都会让本用例超时而不是静默通过。 - MockDataChannel channel; - LoopbackLink link(LoopbackConfig{.sent_capacity = 4096}); - Bridge bridge(channel, link, TestConfig()); + REQUIRE(bridge.Start().has_value()); - // 阶段一:Start() 之前。 - bridge.SetPaused(true); - CHECK(bridge.Paused()); - bridge.SetPaused(false); + // Phase two: rapid toggling while running. The confirmation bit is cleared + // at the top of every round, so a later SetPaused(true) does not read the + // previous round's leftover value and return early -- returning early would + // mean the guarantee is void. + for (int i = 0; i < 50; ++i) { + bridge.SetPaused(true); + bridge.SetPaused(false); + } - REQUIRE(bridge.Start().has_value()); + // After the toggling the real pause guarantee is still in effect. + bridge.SetPaused(true); + constexpr std::uint32_t kCount = 16; + CHECK(channel.InjectFromDevice(MakeFrames(kCount, 256)) == kCount); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + CHECK(link.TotalSentFrames() == 0); - // 阶段二:运行中快速 toggle。确认位每轮循环开头清零,后一次 SetPaused(true) - // 才不会读到上一轮的残留值就提前返回 —— 提前返回等于保证失效。 - for (int i = 0; i < 50; ++i) { + bridge.Stop(); + + // Phase three: after Stop(). bridge.SetPaused(true); - bridge.SetPaused(false); + CHECK(bridge.Paused()); } - // toggle 之后仍然拿得到真实的暂停保证。 - bridge.SetPaused(true); - constexpr std::uint32_t kCount = 16; - CHECK(channel.InjectFromDevice(MakeFrames(kCount, 256)) == kCount); - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - CHECK(link.TotalSentFrames() == 0); - - bridge.Stop(); + TEST_CASE("While paused, frames in the TX direction are dropped and counted") { + // Unlike RX: on the TX side a pause must drop, because during an RNDIS + // reset the device discards every unfinished packet, and hoarding them + // only wastes memory. + MockDataChannel channel; + LoopbackLink link(LoopbackConfig{.inbound_capacity = 512, .max_frames_per_batch = 64}); + Bridge bridge(channel, link, TestConfig()); + REQUIRE(bridge.Start().has_value()); + bridge.SetPaused(true); - // 阶段三:Stop() 之后。 - bridge.SetPaused(true); - CHECK(bridge.Paused()); -} + for (const std::vector& frame : MakeFrames(50, 256)) { + REQUIRE(link.PushInbound(frame)); + } -TEST_CASE("暂停期间 TX 方向的帧被丢弃并计数") { - // 与 RX 不同:TX 侧暂停时必须丢,因为 RNDIS 复位期间设备会丢弃所有未完成的 - // 数据包,攒着只是浪费内存。 - MockDataChannel channel; - LoopbackLink link(LoopbackConfig{.inbound_capacity = 512, .max_frames_per_batch = 64}); - Bridge bridge(channel, link, TestConfig()); - REQUIRE(bridge.Start().has_value()); - bridge.SetPaused(true); + REQUIRE(WaitFor([&] { return bridge.Snapshot().tx.TotalDropped() > 0; })); + CHECK(channel.SentFrameCount() == 0); - for (const std::vector& frame : MakeFrames(50, 256)) { - REQUIRE(link.PushInbound(frame)); + bridge.Stop(); } - REQUIRE(WaitFor([&] { return bridge.Snapshot().tx.TotalDropped() > 0; })); - CHECK(channel.SentFrameCount() == 0); - - bridge.Stop(); -} + TEST_CASE("A link write failure does not kill the thread; it keeps working after recovery") { + MockDataChannel channel; + LoopbackLink link(LoopbackConfig{.sent_capacity = 4096}); + link.FailWritesAfter(1); // every write from the 2nd one on fails + Bridge bridge(channel, link, TestConfig()); + REQUIRE(bridge.Start().has_value()); + + for (int round = 0; round < 5; ++round) { + // It does not matter how many frames were queued; all we need is enough + // traffic to keep writing to the link. + (void)channel.InjectFromDevice(MakeFrames(16, 256)); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } -TEST_CASE("链路写失败不会让线程退出,恢复后继续工作") { - MockDataChannel channel; - LoopbackLink link(LoopbackConfig{.sent_capacity = 4096}); - link.FailWritesAfter(1); // 第 2 次写起失败 - Bridge bridge(channel, link, TestConfig()); - REQUIRE(bridge.Start().has_value()); + // The key point: the bridge is still running, it neither crashed nor exited + // because of the write failure. + CHECK(bridge.Running()); + CHECK(bridge.Snapshot().rx.io_errors > 0); - for (int round = 0; round < 5; ++round) { - // 这里不关心入队了几帧,只要制造出「反复写链路」的流量即可。 - (void)channel.InjectFromDevice(MakeFrames(16, 256)); - std::this_thread::sleep_for(std::chrono::milliseconds(5)); + bridge.Stop(); } - // 关键:桥接层仍在运行,没有因为写失败而崩掉或退出。 - CHECK(bridge.Running()); - CHECK(bridge.Snapshot().rx.io_errors > 0); + TEST_CASE("A USB submission failure is recorded as an I/O error, the bridge keeps running") { + MockDataChannel channel; + LoopbackLink link(LoopbackConfig{.inbound_capacity = 512, .max_frames_per_batch = 64}); + Bridge bridge(channel, link, TestConfig()); + channel.SetSendShouldFail(true); + REQUIRE(bridge.Start().has_value()); - bridge.Stop(); -} + for (const std::vector& frame : MakeFrames(50, 256)) { + REQUIRE(link.PushInbound(frame)); + } -TEST_CASE("USB 提交失败被记为 I/O 错误,桥接层继续运行") { - MockDataChannel channel; - LoopbackLink link(LoopbackConfig{.inbound_capacity = 512, .max_frames_per_batch = 64}); - Bridge bridge(channel, link, TestConfig()); - channel.SetSendShouldFail(true); - REQUIRE(bridge.Start().has_value()); + REQUIRE(WaitFor([&] { return bridge.Snapshot().tx.io_errors > 0; })); + CHECK(bridge.Running()); - for (const std::vector& frame : MakeFrames(50, 256)) { - REQUIRE(link.PushInbound(frame)); + bridge.Stop(); } - REQUIRE(WaitFor([&] { return bridge.Snapshot().tx.io_errors > 0; })); - CHECK(bridge.Running()); - - bridge.Stop(); -} - TEST_CASE("stats line renders rates and drops") { // The stats line is user-facing text; pin the language before asserting. const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kEnglish}; @@ -501,14 +531,15 @@ TEST_CASE("stats line renders rates and drops") { bridge.Stop(); } -TEST_CASE("停机时不丢已在队列里的统计,且能在有流量时安全停下") { +TEST_CASE("Shutdown keeps the statistics already queued, and stops safely under traffic") { MockDataChannel channel; LoopbackLink link(LoopbackConfig{.inbound_capacity = 8192, .sent_capacity = 8192, .max_frames_per_batch = 64}); Bridge bridge(channel, link, TestConfig()); REQUIRE(bridge.Start().has_value()); - // 一边有流量一边停机 —— 这是最容易暴露拆除顺序问题的场景。 + // Stopping while traffic flows -- this is the scenario that exposes + // teardown-order problems most easily. std::atomic keep_going{true}; std::thread producer([&] { while (keep_going.load(std::memory_order_acquire)) { @@ -521,7 +552,7 @@ TEST_CASE("停机时不丢已在队列里的统计,且能在有流量时安全 }); std::this_thread::sleep_for(std::chrono::milliseconds(50)); - bridge.Stop(); // 有流量时停机 + bridge.Stop(); // stop while traffic flows keep_going.store(false, std::memory_order_release); producer.join(); diff --git a/tests/test_main.cc b/tests/test_main.cc index fcd0a72..469d050 100644 --- a/tests/test_main.cc +++ b/tests/test_main.cc @@ -1,6 +1,7 @@ -// doctest 的 main() 实现单独占一个翻译单元。 +// doctest's main() lives in its own translation unit. // -// 这样修改任何测试文件都不必重编译 doctest 那 7000 行头文件的实现部分, -// 显著缩短增量构建时间。 +// This way, editing any test file never recompiles the implementation part of +// the 7000-line doctest header, which significantly shortens incremental build +// times. #define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include diff --git a/tests/test_net_link.cc b/tests/test_net_link.cc index 62958f6..39087d2 100644 --- a/tests/test_net_link.cc +++ b/tests/test_net_link.cc @@ -1,9 +1,11 @@ -// 链路层测试。 +// Link layer tests. // -// 分两部分: -// * loopback 后端 —— 任何环境都能跑,是桥接层测试的基础设施,必须自身可靠; -// * feth + BPF —— 需要 root,非 root 环境下**跳过而非失败**。 -// 用 TETHERKIT_ROOT_TESTS=1 显式开启(避免 CI 上误跑真实网卡操作)。 +// In two parts: +// * loopback backend -- runnable in any environment; it is the infrastructure for bridge +// layer tests and must itself be reliable; +// * feth + BPF -- requires root and is skipped rather than failed in non-root environments. +// Enabled explicitly with TETHERKIT_ROOT_TESTS=1 (to avoid accidentally running real +// interface operations in CI). #include #include #include @@ -36,10 +38,11 @@ using namespace tetherkit::net; // NOLINT(google-build-using-namespace) namespace { -/// 造一帧合法以太帧:目的 MAC、源 MAC、EtherType,再填可校验的净荷。 +/// Build a valid Ethernet frame: destination MAC, source MAC, EtherType, then a verifiable +/// payload. std::vector MakeEthernetFrame(std::uint32_t total_length, std::uint8_t tag) { std::vector frame(total_length); - // 目的 MAC:广播;源 MAC:02:00:00:00:00:tag(本地管理单播)。 + // Destination MAC: broadcast; source MAC: 02:00:00:00:00:tag (locally administered unicast). for (int i = 0; i < 6; ++i) { frame[static_cast(i)] = std::byte{0xFF}; } @@ -53,20 +56,21 @@ std::vector MakeEthernetFrame(std::uint32_t total_length, std::uint8_ return frame; } -/// 需要 root 的测试是否启用。 +/// Whether tests that require root are enabled. /// -/// getenv 在多线程下不安全,但这里是测试用例开头的一次性读取,进程内没有 -/// 并发的 setenv,因此豁免检查。 +/// getenv is not safe in multithreaded code, but this is a one-time read at the start of a +/// test case and there is no concurrent setenv in the process, so the check is suppressed. // NOLINTNEXTLINE(concurrency-mt-unsafe) bool RootTestsEnabled() { const char* flag = std::getenv("TETHERKIT_ROOT_TESTS"); return flag != nullptr && flag[0] == '1' && IsRunningAsRoot(); } -/// 把 Result/Status 的错误渲染成字符串,成功时返回空串。 +/// Render a Result/Status error as a string; returns an empty string on success. /// -/// 直接在 REQUIRE_MESSAGE 里写 `r ? "" : r.error().ToString()` 编译不过 —— -/// doctest 的 MessageBuilder 会把三元表达式整体吞进流里再试图转 bool。 +/// Writing `r ? "" : r.error().ToString()` directly inside REQUIRE_MESSAGE does not compile -- +/// doctest's MessageBuilder consumes the whole ternary expression into the stream and then +/// tries to convert it to bool. template std::string Why(const T& result) { return result.has_value() ? std::string{} : result.error().ToString(); @@ -86,22 +90,24 @@ void ReportSkip(std::string_view what) { } // --------------------------------------------------------------------------- -// ARP 往返闭环用的小工具 +// Small helpers for the ARP round-trip loopback // -// 这个闭环要证明的事情,比「读不回自己写的帧」强得多:BPF 的 write() 真的把帧 -// 送进了**对侧 feth 的 IP 栈**,而不只是写进了一个黑洞。判据是对侧 IP 栈**主动 -// 应答** —— 它不理解这一帧就不会回 ARP reply。 +// This loopback proves something much stronger than "we do not read back our own frame": +// BPF write() really delivers the frame into the IP stack of the feth on the other side, +// not just into a black hole. The criterion is that the other side's IP stack actively +// replies -- it will not send an ARP reply unless it understood the frame. // --------------------------------------------------------------------------- -/// 闭环用的地址。选 10.99.99/24 是因为它几乎不会与真实网络冲突。 -constexpr const char* kArpSystemIp = "10.99.99.1"; ///< 配在系统侧 feth 上。 -constexpr const char* kArpProbeIp = "10.99.99.2"; ///< 我们伪装的提问者。 +/// Addresses for the loopback. 10.99.99/24 is chosen because it almost never conflicts with +/// real networks. +constexpr const char* kArpSystemIp = "10.99.99.1"; ///< Assigned to the system-side feth. +constexpr const char* kArpProbeIp = "10.99.99.2"; ///< The requester we impersonate. -/// 给接口配一个 IPv4 地址。 +/// Assign an IPv4 address to an interface. /// -/// 这里直接用 SIOCAIFADDR 而不是走 capi 的 `ipconfig` 路径:测试要的是一个 -/// 立即生效、进程退出即随 feth 一起消失的地址,不需要 IPConfiguration 的 -/// 租约管理,也不该在测试里拉起子进程。 +/// Use SIOCAIFADDR directly instead of the capi `ipconfig` path: the test needs an address +/// that takes effect immediately and disappears with the feth when the process exits, without +/// IPConfiguration lease management and without spawning a subprocess in the test. [[nodiscard]] std::string AssignIpv4(std::string_view interface_name, const char* address, const char* netmask) { const int fd = ::socket(AF_INET, SOCK_DGRAM, 0); @@ -126,7 +132,7 @@ constexpr const char* kArpProbeIp = "10.99.99.2"; ///< 我们伪装的提问 if (!fill(request.ifra_addr, address) || !fill(request.ifra_mask, netmask)) { ::close(fd); - return "inet_pton 失败"; + return "inet_pton failed"; } const int result = ::ioctl(fd, SIOCAIFADDR, &request); @@ -138,14 +144,14 @@ constexpr const char* kArpProbeIp = "10.99.99.2"; ///< 我们伪装的提问 return {}; } -/// 以太帧内的 ARP 字段偏移(帧首起算)。 +/// ARP field offsets inside an Ethernet frame (counting from the start of the frame). constexpr std::size_t kEtherTypeOffset = 12; constexpr std::size_t kArpOperationOffset = 20; constexpr std::size_t kArpSenderMacOffset = 22; constexpr std::size_t kArpSenderIpOffset = 28; -constexpr std::size_t kArpFrameBytes = 42; ///< 14 以太头 + 28 ARP。 +constexpr std::size_t kArpFrameBytes = 42; ///< 14-byte Ethernet header + 28-byte ARP. -/// 造一个「谁是 target_ip?告诉 sender_ip」的 ARP 请求(广播)。 +/// Build an ARP request "who has target_ip? tell sender_ip" (broadcast). std::vector MakeArpRequest(const MacAddress& sender_mac, const char* sender_ip, const char* target_ip) { std::vector frame(kArpFrameBytes, std::byte{0}); @@ -156,12 +162,12 @@ std::vector MakeArpRequest(const MacAddress& sender_mac, const char* } }; - put(0, {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}); // 目的 MAC:广播 + put(0, {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}); // Destination MAC: broadcast for (std::size_t i = 0; i < sender_mac.size(); ++i) { frame[6 + i] = std::byte{sender_mac[i]}; } put(kEtherTypeOffset, {0x08, 0x06}); // EtherType = ARP - put(14, {0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x01}); // 以太/IPv4,oper = 1(请求) + put(14, {0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x01}); // Ethernet/IPv4, oper = 1 (request) for (std::size_t i = 0; i < sender_mac.size(); ++i) { frame[kArpSenderMacOffset + i] = std::byte{sender_mac[i]}; @@ -170,11 +176,11 @@ std::vector MakeArpRequest(const MacAddress& sender_mac, const char* ::inet_pton(AF_INET, sender_ip, &parsed); std::memcpy(frame.data() + kArpSenderIpOffset, &parsed, sizeof(parsed)); ::inet_pton(AF_INET, target_ip, &parsed); - std::memcpy(frame.data() + 38, &parsed, sizeof(parsed)); // 目标 IP,目标 MAC 留 0 + std::memcpy(frame.data() + 38, &parsed, sizeof(parsed)); // Target IP; target MAC left as 0 return frame; } -/// 判断一帧是否是「宣称拥有 expected_ip」的 ARP reply。 +/// Return whether a frame is an ARP reply claiming to own expected_ip. [[nodiscard]] bool IsArpReplyFor(const FrameView& view, const char* expected_ip) { if (view.length < kArpFrameBytes) { return false; @@ -183,7 +189,7 @@ std::vector MakeArpRequest(const MacAddress& sender_mac, const char* view.data[kEtherTypeOffset + 1] != std::byte{0x06}) { return false; } - // oper == 2 即 reply。 + // oper == 2 means reply. if (view.data[kArpOperationOffset] != std::byte{0x00} || view.data[kArpOperationOffset + 1] != std::byte{0x02}) { return false; @@ -198,30 +204,29 @@ std::vector MakeArpRequest(const MacAddress& sender_mac, const char* } // namespace TEST_SUITE("net.abi") { + TEST_CASE("private ABI struct sizes and ioctl numbers match measured values") { + // These are static_asserts, so compilation alone proves them; confirm at runtime here too + // so the ABI assumptions are visible in the test report. + CHECK(sizeof(IfDrv) == 40); + CHECK(sizeof(FethRequest) == 160); + CHECK(offsetof(FethRequest, u) == 32); + CHECK(kSetDriverSpec == 0x8028'697BUL); + CHECK(kGetDriverSpec == 0xC028'697BUL); + CHECK(kBpfSetBatchWrite == 0x8004'428FUL); + CHECK(kBpfSetNoTimestamp == 0x8004'4291UL); + CHECK(static_cast(FethSetCommand::kSetPeer) == 1); + CHECK(static_cast(FethGetCommand::kGetPeer) == 1); + } -TEST_CASE("私有 ABI 的结构体大小与 ioctl 编号与实测值一致") { - // 这些都是 static_assert,能编译过就说明成立;这里再运行期确认一遍, - // 让 ABI 假设在测试报告里是**可见**的。 - CHECK(sizeof(IfDrv) == 40); - CHECK(sizeof(FethRequest) == 160); - CHECK(offsetof(FethRequest, u) == 32); - CHECK(kSetDriverSpec == 0x8028'697BUL); - CHECK(kGetDriverSpec == 0xC028'697BUL); - CHECK(kBpfSetBatchWrite == 0x8004'428FUL); - CHECK(kBpfSetNoTimestamp == 0x8004'4291UL); - CHECK(static_cast(FethSetCommand::kSetPeer) == 1); - CHECK(static_cast(FethGetCommand::kGetPeer) == 1); -} - -TEST_CASE("BPF 记录头的大小陷阱:sizeof 是 20 但 bh_hdrlen 是 18") { - // 这是 BPF 解析最容易出错的地方,用测试把它钉死。 - CHECK(sizeof(struct bpf_hdr) == 20); - CHECK(sizeof(struct BPF_TIMEVAL) == 8); - CHECK(kBpfHeaderMinBytes == 18); - CHECK(BPF_ALIGNMENT == 4); - // 内核对 DLT_EN10MB 的推导:BPF_WORDALIGN(14 + 18) - 14 = 18。 - CHECK(BPF_WORDALIGN(14 + kBpfHeaderMinBytes) - 14 == kBpfHeaderMinBytes); -} + TEST_CASE("BPF record header size trap: sizeof is 20 but bh_hdrlen is 18") { + // This is the easiest place to get BPF parsing wrong; pin it down with a test. + CHECK(sizeof(struct bpf_hdr) == 20); + CHECK(sizeof(struct BPF_TIMEVAL) == 8); + CHECK(kBpfHeaderMinBytes == 18); + CHECK(BPF_ALIGNMENT == 4); + // Kernel derivation for DLT_EN10MB: BPF_WORDALIGN(14 + 18) - 14 = 18. + CHECK(BPF_WORDALIGN(14 + kBpfHeaderMinBytes) - 14 == kBpfHeaderMinBytes); + } TEST_CASE("feth creation-time sysctl list is non-empty and each entry has a reason") { CHECK(std::size(kRequiredFethSysctls) > 0); @@ -239,323 +244,335 @@ TEST_CASE("feth creation-time sysctl list is non-empty and each entry has a reas } // TEST_SUITE("net.abi") TEST_SUITE("net.loopback") { - -TEST_CASE("空队列读取返回空批次") { - LoopbackLink link; - const auto batch = link.ReadFrames(); - REQUIRE(batch.has_value()); - CHECK(batch->frames.empty()); -} - -TEST_CASE("注入的帧能按 FIFO 顺序读出") { - LoopbackLink link; - for (std::uint8_t i = 0; i < 5; ++i) { - REQUIRE(link.PushInbound(MakeEthernetFrame(100, i))); + TEST_CASE("reading from an empty queue returns an empty batch") { + LoopbackLink link; + const auto batch = link.ReadFrames(); + REQUIRE(batch.has_value()); + CHECK(batch->frames.empty()); } - const auto batch = link.ReadFrames(); - REQUIRE(batch.has_value()); - REQUIRE(batch->frames.size() == 5); - for (std::size_t i = 0; i < 5; ++i) { - CHECK(batch->frames[i].length == 100); - // 源 MAC 的最后一字节是我们塞的序号。 - CHECK(batch->frames[i].data[11] == std::byte{static_cast(i)}); - } -} -TEST_CASE("单批读取受 max_frames_per_batch 限制,剩余留到下一批") { - LoopbackLink link(LoopbackConfig{.max_frames_per_batch = 3}); - for (std::uint8_t i = 0; i < 7; ++i) { - REQUIRE(link.PushInbound(MakeEthernetFrame(64, i))); + TEST_CASE("injected frames are read out in FIFO order") { + LoopbackLink link; + for (std::uint8_t i = 0; i < 5; ++i) { + REQUIRE(link.PushInbound(MakeEthernetFrame(100, i))); + } + const auto batch = link.ReadFrames(); + REQUIRE(batch.has_value()); + REQUIRE(batch->frames.size() == 5); + for (std::size_t i = 0; i < 5; ++i) { + CHECK(batch->frames[i].length == 100); + // The last byte of the source MAC is the sequence number we inserted. + CHECK(batch->frames[i].data[11] == std::byte{static_cast(i)}); + } } - const auto first = link.ReadFrames(); - REQUIRE(first.has_value()); - CHECK(first->frames.size() == 3); - const auto second = link.ReadFrames(); - REQUIRE(second.has_value()); - CHECK(second->frames.size() == 3); + TEST_CASE( + "a single read batch is limited by max_frames_per_batch; the rest stays for the next batch") { + LoopbackLink link(LoopbackConfig{.max_frames_per_batch = 3}); + for (std::uint8_t i = 0; i < 7; ++i) { + REQUIRE(link.PushInbound(MakeEthernetFrame(64, i))); + } + const auto first = link.ReadFrames(); + REQUIRE(first.has_value()); + CHECK(first->frames.size() == 3); - const auto third = link.ReadFrames(); - REQUIRE(third.has_value()); - CHECK(third->frames.size() == 1); -} + const auto second = link.ReadFrames(); + REQUIRE(second.has_value()); + CHECK(second->frames.size() == 3); -TEST_CASE("注入队列满时丢弃并计数") { - LoopbackLink link(LoopbackConfig{.inbound_capacity = 2}); - CHECK(link.PushInbound(MakeEthernetFrame(64, 1))); - CHECK(link.PushInbound(MakeEthernetFrame(64, 2))); - CHECK_FALSE(link.PushInbound(MakeEthernetFrame(64, 3))); - CHECK(link.InboundDrops() == 1); -} + const auto third = link.ReadFrames(); + REQUIRE(third.has_value()); + CHECK(third->frames.size() == 1); + } -TEST_CASE("超长帧被拒绝注入") { - LoopbackLink link(LoopbackConfig{.max_frame_bytes = 128}); - CHECK_FALSE(link.PushInbound(MakeEthernetFrame(129, 1))); - CHECK(link.InboundDrops() == 1); -} + TEST_CASE("drops and counts when the inbound queue is full") { + LoopbackLink link(LoopbackConfig{.inbound_capacity = 2}); + CHECK(link.PushInbound(MakeEthernetFrame(64, 1))); + CHECK(link.PushInbound(MakeEthernetFrame(64, 2))); + CHECK_FALSE(link.PushInbound(MakeEthernetFrame(64, 3))); + CHECK(link.InboundDrops() == 1); + } -TEST_CASE("写出的帧可被取回,内容完整") { - LoopbackLink link; - const auto frame_a = MakeEthernetFrame(1514, 0xA1); - const auto frame_b = MakeEthernetFrame(64, 0xB2); - const std::array batch{ - FrameView{.data = frame_a.data(), .length = static_cast(frame_a.size())}, - FrameView{.data = frame_b.data(), .length = static_cast(frame_b.size())}, - }; + TEST_CASE("oversized frames are rejected on injection") { + LoopbackLink link(LoopbackConfig{.max_frame_bytes = 128}); + CHECK_FALSE(link.PushInbound(MakeEthernetFrame(129, 1))); + CHECK(link.InboundDrops() == 1); + } - const auto result = link.WriteFrames(batch); - REQUIRE(result.has_value()); - CHECK(result->frames_written == 2); - CHECK(result->bytes_written == 1514 + 64); - CHECK(result->frames_skipped == 0); - - const auto sent = link.DrainSent(); - REQUIRE(sent.size() == 2); - CHECK(sent[0] == frame_a); - CHECK(sent[1] == frame_b); - // DrainSent 不影响累计计数。 - CHECK(link.TotalSentFrames() == 2); - CHECK(link.TotalSentBytes() == 1514 + 64); - // 取空之后再取应为空。 - CHECK(link.DrainSent().empty()); -} + TEST_CASE("written frames can be retrieved with intact contents") { + LoopbackLink link; + const auto frame_a = MakeEthernetFrame(1514, 0xA1); + const auto frame_b = MakeEthernetFrame(64, 0xB2); + const std::array batch{ + FrameView{.data = frame_a.data(), .length = static_cast(frame_a.size())}, + FrameView{.data = frame_b.data(), .length = static_cast(frame_b.size())}, + }; + + const auto result = link.WriteFrames(batch); + REQUIRE(result.has_value()); + CHECK(result->frames_written == 2); + CHECK(result->bytes_written == 1514 + 64); + CHECK(result->frames_skipped == 0); + + const auto sent = link.DrainSent(); + REQUIRE(sent.size() == 2); + CHECK(sent[0] == frame_a); + CHECK(sent[1] == frame_b); + // DrainSent does not affect cumulative counters. + CHECK(link.TotalSentFrames() == 2); + CHECK(link.TotalSentBytes() == 1514 + 64); + // Draining again after empty should yield empty. + CHECK(link.DrainSent().empty()); + } -TEST_CASE("写出时跳过过短与过长的帧") { - LoopbackLink link(LoopbackConfig{.max_frame_bytes = 200}); - const auto too_short = MakeEthernetFrame(14, 1); - std::vector shorter(13); - const auto too_long = MakeEthernetFrame(201, 2); - const auto valid = MakeEthernetFrame(100, 3); - - const std::array batch{ - FrameView{.data = shorter.data(), .length = 13}, - FrameView{.data = too_long.data(), .length = 201}, - FrameView{.data = valid.data(), .length = 100}, - }; - const auto result = link.WriteFrames(batch); - REQUIRE(result.has_value()); - CHECK(result->frames_written == 1); - CHECK(result->frames_skipped == 2); - // 14 字节恰好是最小合法帧,应被接受。 - const std::array minimal{FrameView{.data = too_short.data(), .length = 14}}; - const auto minimal_result = link.WriteFrames(minimal); - REQUIRE(minimal_result.has_value()); - CHECK(minimal_result->frames_written == 1); -} + TEST_CASE("skips frames that are too short or too long on write") { + LoopbackLink link(LoopbackConfig{.max_frame_bytes = 200}); + const auto too_short = MakeEthernetFrame(14, 1); + std::vector shorter(13); + const auto too_long = MakeEthernetFrame(201, 2); + const auto valid = MakeEthernetFrame(100, 3); + + const std::array batch{ + FrameView{.data = shorter.data(), .length = 13}, + FrameView{.data = too_long.data(), .length = 201}, + FrameView{.data = valid.data(), .length = 100}, + }; + const auto result = link.WriteFrames(batch); + REQUIRE(result.has_value()); + CHECK(result->frames_written == 1); + CHECK(result->frames_skipped == 2); + // 14 bytes is exactly the minimum valid frame and should be accepted. + const std::array minimal{FrameView{.data = too_short.data(), .length = 14}}; + const auto minimal_result = link.WriteFrames(minimal); + REQUIRE(minimal_result.has_value()); + CHECK(minimal_result->frames_written == 1); + } -TEST_CASE("写出队列满时停止写入,如实反映已写数") { - LoopbackLink link(LoopbackConfig{.sent_capacity = 2}); - const auto frame = MakeEthernetFrame(64, 1); - std::vector batch(5, FrameView{.data = frame.data(), .length = 64}); - const auto result = link.WriteFrames(batch); - REQUIRE(result.has_value()); - CHECK(result->frames_written == 2); -} + TEST_CASE( + "stops writing when the outbound queue is full and reports the written count accurately") { + LoopbackLink link(LoopbackConfig{.sent_capacity = 2}); + const auto frame = MakeEthernetFrame(64, 1); + std::vector batch(5, FrameView{.data = frame.data(), .length = 64}); + const auto result = link.WriteFrames(batch); + REQUIRE(result.has_value()); + CHECK(result->frames_written == 2); + } -TEST_CASE("Interrupt 后读取立即返回空批次") { - LoopbackLink link; - REQUIRE(link.PushInbound(MakeEthernetFrame(64, 1))); - link.Interrupt(); - CHECK(link.Interrupted()); - const auto batch = link.ReadFrames(); - REQUIRE(batch.has_value()); - CHECK(batch->frames.empty()); -} + TEST_CASE("ReadFrames returns an empty batch immediately after Interrupt") { + LoopbackLink link; + REQUIRE(link.PushInbound(MakeEthernetFrame(64, 1))); + link.Interrupt(); + CHECK(link.Interrupted()); + const auto batch = link.ReadFrames(); + REQUIRE(batch.has_value()); + CHECK(batch->frames.empty()); + } -TEST_CASE("可以注入写失败以测试错误传播") { - LoopbackLink link; - link.FailWritesAfter(1); // 第 1 次成功,第 2 次起失败 - const auto frame = MakeEthernetFrame(64, 1); - const std::array batch{FrameView{.data = frame.data(), .length = 64}}; + TEST_CASE("can inject write failures to test error propagation") { + LoopbackLink link; + link.FailWritesAfter(1); // 1st succeeds, 2nd onward fail + const auto frame = MakeEthernetFrame(64, 1); + const std::array batch{FrameView{.data = frame.data(), .length = 64}}; - CHECK(link.WriteFrames(batch).has_value()); - CHECK_FALSE(link.WriteFrames(batch).has_value()); -} + CHECK(link.WriteFrames(batch).has_value()); + CHECK_FALSE(link.WriteFrames(batch).has_value()); + } -TEST_CASE("空批次写入是合法的空操作") { - LoopbackLink link; - const auto result = link.WriteFrames({}); - REQUIRE(result.has_value()); - CHECK(result->frames_written == 0); -} + TEST_CASE("writing an empty batch is a legal no-op") { + LoopbackLink link; + const auto result = link.WriteFrames({}); + REQUIRE(result.has_value()); + CHECK(result->frames_written == 0); + } } // TEST_SUITE("net.loopback") TEST_SUITE("net.feth" * doctest::skip(false)) { - -TEST_CASE("查询 feth MTU 上限(只读 sysctl,无需 root)") { - const auto max_mtu = QueryFethMaxMtu(); - REQUIRE(max_mtu.has_value()); - // 内核保证 >= ETHERMTU(1500)。 - CHECK(*max_mtu >= 1500); - MESSAGE(std::format("net.link.fake.max_mtu = {}", *max_mtu)); -} - -TEST_CASE("校验 feth 创建期 sysctl(只读,无需 root)") { - const auto status = VerifyFethSysctls(); - if (!status) { - // 不是测试失败 —— 是这台机器的 sysctl 被改过。报出来让人知道。 - MESSAGE(std::format("本机 feth sysctl 不满足要求:{}", status.error().ToString())); + TEST_CASE("query feth MTU limit (read-only sysctl, no root required)") { + const auto max_mtu = QueryFethMaxMtu(); + REQUIRE(max_mtu.has_value()); + // The kernel guarantees >= ETHERMTU(1500). + CHECK(*max_mtu >= 1500); + MESSAGE(std::format("net.link.fake.max_mtu = {}", *max_mtu)); } - // 只要能读到就算通过;具体值取决于机器配置。 - CHECK(true); -} -TEST_CASE("非 root 环境下创建 feth 必须给出明确的权限错误") { - if (IsRunningAsRoot()) { - MESSAGE("当前以 root 运行,跳过权限错误检查"); - return; + TEST_CASE("verify feth creation-time sysctls (read-only, no root required)") { + const auto status = VerifyFethSysctls(); + if (!status) { + // Not a test failure -- the sysctls on this machine have been changed. Report it so + // people know. + MESSAGE(std::format("local feth sysctls do not meet requirements: {}", + status.error().ToString())); + } + // As long as the values can be read the test passes; actual values depend on machine + // configuration. + CHECK(true); } - const auto pair = FethPair::Create(1500); - REQUIRE_FALSE(pair.has_value()); - const std::string message = pair.error().ToString(); - // 错误必须提到 root / sudo,而不是丢一个裸 EPERM 给用户。 - CHECK((message.find("root") != std::string::npos || message.find("sudo") != std::string::npos)); -} - -TEST_CASE("MAC 格式化") { - const MacAddress mac{0x66, 0x65, 0x74, 0x68, 0x00, 0x00}; - // 内核给 feth0 分配的正是这个地址('f','e','t','h', unit>>8, unit&0xff)。 - CHECK(std::string{FormatMac(mac).data()} == "66:65:74:68:00:00"); -} - -TEST_CASE("完整生命周期:创建、配对、设 MTU/MAC、UP、销毁") { - if (!RootTestsEnabled()) { - ReportSkip("feth 完整生命周期测试"); - return; - } - - const MacAddress device_mac{0x02, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE}; - auto pair = FethPair::Create(1500, &device_mac); - REQUIRE_MESSAGE(pair.has_value(), Why(pair)); - - const std::string system_name{pair->SystemSide().Name()}; - const std::string driver_name{pair->DriverSide().Name()}; - MESSAGE(std::format("created {} <-> {}", system_name, driver_name)); - - // 配对关系双向成立。 - const auto driver_peer = pair->DriverSide().QueryPeer(); - REQUIRE(driver_peer.has_value()); - CHECK(*driver_peer == system_name); - const auto system_peer = pair->SystemSide().QueryPeer(); - REQUIRE(system_peer.has_value()); - CHECK(*system_peer == driver_name); - - // MTU 两侧一致。 - CHECK(pair->SystemSide().QueryMtu().value_or(0) == 1500); - CHECK(pair->DriverSide().QueryMtu().value_or(0) == 1500); - - // 系统侧 MAC 是我们设的,驱动侧保持内核分配的(两者必须不同)。 - const auto system_mac = pair->SystemSide().QueryMacAddress(); - REQUIRE(system_mac.has_value()); - CHECK(*system_mac == device_mac); - const auto driver_mac = pair->DriverSide().QueryMacAddress(); - REQUIRE(driver_mac.has_value()); - CHECK(*driver_mac != device_mac); - - // 两侧都 UP —— bpfwrite 硬性要求驱动侧是 UP。 - CHECK(pair->SystemSide().IsUp().value_or(false)); - CHECK(pair->DriverSide().IsUp().value_or(false)); -} -TEST_CASE("BPF 打开配置全流程,并验证方向语义与回环抑制") { - if (!RootTestsEnabled()) { - ReportSkip("BPF 收发测试"); - return; + TEST_CASE("creating feth in a non-root environment must produce a clear permission error") { + if (IsRunningAsRoot()) { + MESSAGE("running as root, skipping permission-error check"); + return; + } + const auto pair = FethPair::Create(1500); + REQUIRE_FALSE(pair.has_value()); + const std::string message = pair.error().ToString(); + // The error must mention root / sudo rather than handing the user a bare EPERM. + CHECK((message.find("root") != std::string::npos || message.find("sudo") != std::string::npos)); } - auto pair = FethPair::Create(1500); - REQUIRE_MESSAGE(pair.has_value(), Why(pair)); - - // BPF 挂在**驱动侧**。 - auto link = BpfLink::Open(pair->DriverSide().Name(), BpfConfig{}); - REQUIRE_MESSAGE(link.has_value(), Why(link)); - - MESSAGE(std::format("BPF 设备 {},内核缓冲 {} 字节,批量写 {}", (*link)->DevicePath(), - (*link)->KernelBufferBytes(), - (*link)->SupportsBatchWrite() ? "可用" : "不可用")); + TEST_CASE("MAC formatting") { + const MacAddress mac{0x66, 0x65, 0x74, 0x68, 0x00, 0x00}; + // This is exactly the address the kernel assigns to feth0 ('f','e','t','h', unit>>8, + // unit&0xff). + CHECK(std::string{FormatMac(mac).data()} == "66:65:74:68:00:00"); + } - // 内核实际生效的缓冲不应为 0,且不超过 32 MiB 的上限。 - CHECK((*link)->KernelBufferBytes() > 0); - CHECK((*link)->KernelBufferBytes() <= 32U * 1024 * 1024); + TEST_CASE("full lifecycle: create, pair, set MTU/MAC, UP, destroy") { + if (!RootTestsEnabled()) { + ReportSkip("full feth lifecycle test"); + return; + } - const auto stats = (*link)->QueryKernelStats(); - REQUIRE(stats.has_value()); + const MacAddress device_mac{0x02, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE}; + auto pair = FethPair::Create(1500, &device_mac); + REQUIRE_MESSAGE(pair.has_value(), Why(pair)); + + const std::string system_name{pair->SystemSide().Name()}; + const std::string driver_name{pair->DriverSide().Name()}; + MESSAGE(std::format("created {} <-> {}", system_name, driver_name)); + + // The pairing relationship holds in both directions. + const auto driver_peer = pair->DriverSide().QueryPeer(); + REQUIRE(driver_peer.has_value()); + CHECK(*driver_peer == system_name); + const auto system_peer = pair->SystemSide().QueryPeer(); + REQUIRE(system_peer.has_value()); + CHECK(*system_peer == driver_name); + + // MTU is the same on both sides. + CHECK(pair->SystemSide().QueryMtu().value_or(0) == 1500); + CHECK(pair->DriverSide().QueryMtu().value_or(0) == 1500); + + // The system-side MAC is the one we set; the driver side keeps the kernel-assigned one + // (they must differ). + const auto system_mac = pair->SystemSide().QueryMacAddress(); + REQUIRE(system_mac.has_value()); + CHECK(*system_mac == device_mac); + const auto driver_mac = pair->DriverSide().QueryMacAddress(); + REQUIRE(driver_mac.has_value()); + CHECK(*driver_mac != device_mac); + + // Both sides are UP -- bpfwrite requires the driver side to be UP. + CHECK(pair->SystemSide().IsUp().value_or(false)); + CHECK(pair->DriverSide().IsUp().value_or(false)); + } - // 写一帧进去 —— 它应该进入系统侧的 input,**不应该**被我们自己读回来 - // (BIOCSSEESENT=0 过滤掉 output 方向)。 - const auto frame = MakeEthernetFrame(200, 0x5A); - const std::array batch{FrameView{.data = frame.data(), .length = 200}}; - const auto written = (*link)->WriteFrames(batch); - REQUIRE_MESSAGE(written.has_value(), Why(written)); - CHECK(written->frames_written == 1); + TEST_CASE("BPF open/configure flow and direction semantics with loopback suppression") { + if (!RootTestsEnabled()) { + ReportSkip("BPF transmit/receive test"); + return; + } - // 读一次(会在读超时后返回)。绝不应看到自己刚写的那一帧。 - const auto batch_read = (*link)->ReadFrames(); - REQUIRE(batch_read.has_value()); - for (const FrameView& view : batch_read->frames) { - // 我们写的帧源 MAC 第 12 字节是 0x5A;读到它就说明回环抑制失效了。 - const bool is_our_frame = view.length == 200 && view.data[11] == std::byte{0x5A}; - CHECK_FALSE(is_our_frame); + auto pair = FethPair::Create(1500); + REQUIRE_MESSAGE(pair.has_value(), Why(pair)); + + // Attach BPF to the driver side. + auto link = BpfLink::Open(pair->DriverSide().Name(), BpfConfig{}); + REQUIRE_MESSAGE(link.has_value(), Why(link)); + + MESSAGE(std::format("BPF device {}, kernel buffer {} bytes, batch write {}", + (*link)->DevicePath(), (*link)->KernelBufferBytes(), + (*link)->SupportsBatchWrite() ? "available" : "unavailable")); + + // The effective kernel buffer must not be 0 and must not exceed the 32 MiB limit. + CHECK((*link)->KernelBufferBytes() > 0); + CHECK((*link)->KernelBufferBytes() <= 32U * 1024 * 1024); + + const auto stats = (*link)->QueryKernelStats(); + REQUIRE(stats.has_value()); + + // Write a frame -- it should enter the system-side input and must not be read back by us + // (BIOCSSEESENT=0 filters out the output direction). + const auto frame = MakeEthernetFrame(200, 0x5A); + const std::array batch{FrameView{.data = frame.data(), .length = 200}}; + const auto written = (*link)->WriteFrames(batch); + REQUIRE_MESSAGE(written.has_value(), Why(written)); + CHECK(written->frames_written == 1); + + // Read once (returns after the read timeout). We must never see the frame we just wrote. + const auto batch_read = (*link)->ReadFrames(); + REQUIRE(batch_read.has_value()); + for (const FrameView& view : batch_read->frames) { + // Byte 12 of the source MAC of our frame is 0x5A; seeing it means loopback suppression + // failed. + const bool is_our_frame = view.length == 200 && view.data[11] == std::byte{0x5A}; + CHECK_FALSE(is_our_frame); + } } -} -TEST_CASE("ARP 往返闭环:BPF 写入的帧确实进了对侧 feth 的 IP 栈") { - if (!RootTestsEnabled()) { - ReportSkip("ARP 往返闭环测试"); - return; - } - - auto pair = FethPair::Create(1500); - REQUIRE_MESSAGE(pair.has_value(), Why(pair)); - - // 系统侧配一个 IP,好让它的 IP 栈有理由应答 ARP。 - const std::string assign_error = - AssignIpv4(pair->SystemSide().Name(), kArpSystemIp, "255.255.255.0"); - REQUIRE_MESSAGE(assign_error.empty(), assign_error); - - const auto system_mac = pair->SystemSide().QueryMacAddress(); - REQUIRE_MESSAGE(system_mac.has_value(), Why(system_mac)); - - // BPF 挂驱动侧 —— 这一侧扮演「设备」。 - auto link = BpfLink::Open(pair->DriverSide().Name(), BpfConfig{}); - REQUIRE_MESSAGE(link.has_value(), Why(link)); - - const MacAddress probe_mac{0x02, 0x00, 0x00, 0x99, 0x99, 0x02}; - const auto request = MakeArpRequest(probe_mac, kArpProbeIp, kArpSystemIp); - const std::array batch{ - FrameView{.data = request.data(), .length = static_cast(request.size())}}; - - const auto written = (*link)->WriteFrames(batch); - REQUIRE_MESSAGE(written.has_value(), Why(written)); - REQUIRE(written->frames_written == 1); - - // 读若干轮再判定失败:ReadFrames 一轮只等一个读超时,而 IP 栈的应答虽然快, - // 却没有「一定落在第一轮」的保证。 - bool saw_reply = false; - MacAddress reply_mac{}; - for (int attempt = 0; attempt < 20 && !saw_reply; ++attempt) { - const auto received = (*link)->ReadFrames(); - REQUIRE_MESSAGE(received.has_value(), Why(received)); - for (const FrameView& view : received->frames) { - if (IsArpReplyFor(view, kArpSystemIp)) { - saw_reply = true; - for (std::size_t i = 0; i < reply_mac.size(); ++i) { - reply_mac[i] = static_cast(view.data[kArpSenderMacOffset + i]); + TEST_CASE( + "ARP round-trip loopback: a frame written via BPF really reaches the other feth's IP stack") { + if (!RootTestsEnabled()) { + ReportSkip("ARP round-trip loopback test"); + return; + } + + auto pair = FethPair::Create(1500); + REQUIRE_MESSAGE(pair.has_value(), Why(pair)); + + // Assign an IP to the system side so its IP stack has a reason to answer ARP. + const std::string assign_error = + AssignIpv4(pair->SystemSide().Name(), kArpSystemIp, "255.255.255.0"); + REQUIRE_MESSAGE(assign_error.empty(), assign_error); + + const auto system_mac = pair->SystemSide().QueryMacAddress(); + REQUIRE_MESSAGE(system_mac.has_value(), Why(system_mac)); + + // Attach BPF to the driver side -- this side plays the "device". + auto link = BpfLink::Open(pair->DriverSide().Name(), BpfConfig{}); + REQUIRE_MESSAGE(link.has_value(), Why(link)); + + const MacAddress probe_mac{0x02, 0x00, 0x00, 0x99, 0x99, 0x02}; + const auto request = MakeArpRequest(probe_mac, kArpProbeIp, kArpSystemIp); + const std::array batch{ + FrameView{.data = request.data(), .length = static_cast(request.size())}}; + + const auto written = (*link)->WriteFrames(batch); + REQUIRE_MESSAGE(written.has_value(), Why(written)); + REQUIRE(written->frames_written == 1); + + // Read several rounds before declaring failure: one ReadFrames round waits for only one + // read timeout, and although the IP stack replies quickly, there is no guarantee it lands + // in the first round. + bool saw_reply = false; + MacAddress reply_mac{}; + for (int attempt = 0; attempt < 20 && !saw_reply; ++attempt) { + const auto received = (*link)->ReadFrames(); + REQUIRE_MESSAGE(received.has_value(), Why(received)); + for (const FrameView& view : received->frames) { + if (IsArpReplyFor(view, kArpSystemIp)) { + saw_reply = true; + for (std::size_t i = 0; i < reply_mac.size(); ++i) { + reply_mac[i] = static_cast(view.data[kArpSenderMacOffset + i]); + } + break; } - break; } } - } - // 这一条断言就是整个方案的核心前提:对侧 IP 栈**收到并处理了**我们写进去的帧。 - CHECK_MESSAGE(saw_reply, "没有收到系统侧 feth 的 ARP reply —— BPF write 没能送达对侧 IP 栈"); - if (saw_reply) { - // 应答者必须正是系统侧 feth,而不是别的什么接口串进来的帧。 - CHECK(reply_mac == *system_mac); - MESSAGE(std::format("{} 宣称拥有 {},MAC {}", pair->SystemSide().Name(), kArpSystemIp, - FormatMac(reply_mac).data())); + // This assertion is the core premise of the whole design: the other side's IP stack + // received and processed the frame we wrote. + CHECK_MESSAGE(saw_reply, + "did not receive an ARP reply from the system-side feth -- BPF write did not " + "reach the other side's IP stack"); + if (saw_reply) { + // The responder must be the system-side feth itself, not a frame from some other + // interface. + CHECK(reply_mac == *system_mac); + MESSAGE(std::format("{} claims to own {}, MAC {}", pair->SystemSide().Name(), kArpSystemIp, + FormatMac(reply_mac).data())); + } } -} } // TEST_SUITE("net.feth") diff --git a/tests/test_rndis_messages.cc b/tests/test_rndis_messages.cc index 2be7e08..cb045b7 100644 --- a/tests/test_rndis_messages.cc +++ b/tests/test_rndis_messages.cc @@ -1,13 +1,14 @@ -// RNDIS 控制消息编解码的单元测试。 +// Unit tests for RNDIS control-message encoding and decoding. // -// 重点覆盖: -// 1. 偏移字段的基准点是「消息起始 + 8」—— 这是 RNDIS 的第一号陷阱; -// 2. RESET_MSG / RESET_CMPLT **没有** RequestId,字段偏移与其他消息不同; -// 3. 变长 / 定长 OID 的 InformationBufferLength 规则相反; -// 4. INDICATE_STATUS 的 StatusBufferOffset 基准点在规范里是矛盾的; -// 5. 设备 quirk 的归一化(MaxPacketsPerMessage=0、对齐因子越界、 -// MaxTransferSize 过大或过小); -// 6. 全部畸形输入都必须返回错误而非崩溃或读越界。 +// Key coverage: +// 1. The base point for offset fields is "message start + 8" -- this is RNDIS trap number one; +// 2. RESET_MSG / RESET_CMPLT have no RequestId, so their field offsets differ from other +// messages; +// 3. The InformationBufferLength rules for variable-length and fixed-length OIDs are opposite; +// 4. The base point of INDICATE_STATUS StatusBufferOffset is contradictory in the spec; +// 5. Normalization of device quirks (MaxPacketsPerMessage=0, alignment factor out of range, +// MaxTransferSize too large or too small); +// 6. All malformed inputs must return an error rather than crashing or reading out of bounds. #include #include #include @@ -25,7 +26,7 @@ using namespace tetherkit::rndis; // NOLINT(google-build-using-namespace) namespace { -/// 造一条 RNDIS 消息:先写 Type/Length,再由调用方填字段。 +/// Build an RNDIS message: write Type/Length first, then let the caller fill in fields. std::vector MakeMessage(MessageType type, std::uint32_t message_length, std::uint32_t buffer_bytes = 0) { std::vector buffer(buffer_bytes == 0 ? message_length : buffer_bytes); @@ -34,7 +35,7 @@ std::vector MakeMessage(MessageType type, std::uint32_t message_lengt return buffer; } -/// 造一条主线 Linux gadget(Android)会发的 INITIALIZE_CMPLT。 +/// Build an INITIALIZE_CMPLT like the one sent by the mainline Linux gadget (Android). std::vector MakeInitializeComplete(std::uint32_t max_transfer_size = 2048, std::uint32_t max_packets = 1, std::uint32_t alignment_factor = 0, @@ -58,78 +59,79 @@ std::vector MakeInitializeComplete(std::uint32_t max_transfer_size = } // namespace TEST_SUITE("rndis.messages") { + // --------------------------------------------------------------------------- + // Constant self-checks + // --------------------------------------------------------------------------- + + TEST_CASE("protocol constants match the spec") { + CHECK(ToRaw(MessageType::kPacket) == 0x0000'0001U); + CHECK(ToRaw(MessageType::kInitialize) == 0x0000'0002U); + CHECK(ToRaw(MessageType::kInitializeComplete) == 0x8000'0002U); + CHECK(ToRaw(MessageType::kHalt) == 0x0000'0003U); + CHECK(ToRaw(MessageType::kQueryComplete) == 0x8000'0004U); + CHECK(ToRaw(MessageType::kResetComplete) == 0x8000'0006U); + CHECK(ToRaw(MessageType::kIndicateStatus) == 0x0000'0007U); + CHECK(ToRaw(MessageType::kKeepAliveComplete) == 0x8000'0008U); + + // A few status codes that are easy to misremember. + CHECK(ToRaw(StatusCode::kNotSupported) == 0xC000'00BBU); + CHECK(ToRaw(StatusCode::kInvalidLength) == 0xC001'0014U); + CHECK(ToRaw(StatusCode::kInvalidData) == 0xC001'0015U); + CHECK(ToRaw(StatusCode::kInvalidOid) == 0xC001'0017U); + CHECK(ToRaw(StatusCode::kMediaConnect) == 0x4001'000BU); + CHECK(ToRaw(StatusCode::kMediaDisconnect) == 0x4001'000CU); + + CHECK(ToRaw(Oid::kGenCurrentPacketFilter) == 0x0001'010EU); + CHECK(ToRaw(Oid::kGenMediaConnectStatus) == 0x0001'0114U); + CHECK(ToRaw(Oid::kEthernetPermanentAddress) == 0x0101'0101U); + CHECK(ToRaw(Oid::kGenPhysicalMedium) == 0x0001'0202U); + + CHECK(kDefaultPacketFilter == 0x0000'002DU); + CHECK(kHardHeaderBytes == 58); + } -// --------------------------------------------------------------------------- -// 常量自检 -// --------------------------------------------------------------------------- + TEST_CASE("offset base-point constants: the inline DataOffset value must be 36, not 44") { + // This is RNDIS implementation trap number one: the base point for offset fields is + // message start + 8. + CHECK(kPacketInlineDataOffset == 36); + CHECK(kPacketMsgHeaderBytes == 44); + CHECK(kOffsetFieldBase == 8); + CHECK(kQuerySetInlineInfoOffset == 20); + CHECK(kQueryCmpltInlineInfoOffset == 16); + } -TEST_CASE("协议常量与规范一致") { - CHECK(ToRaw(MessageType::kPacket) == 0x0000'0001U); - CHECK(ToRaw(MessageType::kInitialize) == 0x0000'0002U); - CHECK(ToRaw(MessageType::kInitializeComplete) == 0x8000'0002U); - CHECK(ToRaw(MessageType::kHalt) == 0x0000'0003U); - CHECK(ToRaw(MessageType::kQueryComplete) == 0x8000'0004U); - CHECK(ToRaw(MessageType::kResetComplete) == 0x8000'0006U); - CHECK(ToRaw(MessageType::kIndicateStatus) == 0x0000'0007U); - CHECK(ToRaw(MessageType::kKeepAliveComplete) == 0x8000'0008U); - - // 几个最容易记错的状态码。 - CHECK(ToRaw(StatusCode::kNotSupported) == 0xC000'00BBU); - CHECK(ToRaw(StatusCode::kInvalidLength) == 0xC001'0014U); - CHECK(ToRaw(StatusCode::kInvalidData) == 0xC001'0015U); - CHECK(ToRaw(StatusCode::kInvalidOid) == 0xC001'0017U); - CHECK(ToRaw(StatusCode::kMediaConnect) == 0x4001'000BU); - CHECK(ToRaw(StatusCode::kMediaDisconnect) == 0x4001'000CU); - - CHECK(ToRaw(Oid::kGenCurrentPacketFilter) == 0x0001'010EU); - CHECK(ToRaw(Oid::kGenMediaConnectStatus) == 0x0001'0114U); - CHECK(ToRaw(Oid::kEthernetPermanentAddress) == 0x0101'0101U); - CHECK(ToRaw(Oid::kGenPhysicalMedium) == 0x0001'0202U); - - CHECK(kDefaultPacketFilter == 0x0000'002DU); - CHECK(kHardHeaderBytes == 58); -} - -TEST_CASE("偏移基准点常量:DataOffset 内联值必须是 36 而不是 44") { - // 这是 RNDIS 实现的第一号陷阱:偏移字段的基准点是消息起始 +8。 - CHECK(kPacketInlineDataOffset == 36); - CHECK(kPacketMsgHeaderBytes == 44); - CHECK(kOffsetFieldBase == 8); - CHECK(kQuerySetInlineInfoOffset == 20); - CHECK(kQueryCmpltInlineInfoOffset == 16); -} - -TEST_CASE("MaxTransferSize 推导与 Linux 一致") { - CHECK(MaxTransferSizeFor(1500, 512) == 2048); // 高速 - CHECK(MaxTransferSizeFor(1500, 64) == 1600); // 全速 - CHECK(HardMtuFor(1500) == 1558); -} - -TEST_CASE("状态码与 OID 的名字可查") { - CHECK(StatusName(0xC000'00BBU) == "RNDIS_STATUS_NOT_SUPPORTED"); - CHECK(StatusName(0x4001'000BU) == "RNDIS_STATUS_MEDIA_CONNECT"); - CHECK(StatusName(0xDEAD'BEEFU).empty()); - CHECK(OidName(0x0001'010EU) == "OID_GEN_CURRENT_PACKET_FILTER"); - CHECK(OidName(0xDEAD'BEEFU).empty()); - CHECK(MessageTypeName(0x8000'0002U) == "REMOTE_NDIS_INITIALIZE_CMPLT"); - // CONDIS 消息族必须能被识别出来(虽然不支持),而不是报「未知」。 - CHECK(MessageTypeName(0x0000'8001U) == "RNDIS_MSG_MP_CREATE_VC"); - CHECK(IsCondis(0x0000'8001U)); - CHECK_FALSE(IsCondis(ToRaw(MessageType::kPacket))); -} - -TEST_CASE("变长与定长 OID 的区分") { - CHECK(IsVariableLengthOid(Oid::kGenSupportedList)); - CHECK(IsVariableLengthOid(Oid::kGenVendorDescription)); - CHECK_FALSE(IsVariableLengthOid(Oid::kEthernetPermanentAddress)); - CHECK_FALSE(IsVariableLengthOid(Oid::kGenCurrentPacketFilter)); -} + TEST_CASE("MaxTransferSize derivation matches Linux") { + CHECK(MaxTransferSizeFor(1500, 512) == 2048); // high speed + CHECK(MaxTransferSizeFor(1500, 64) == 1600); // full speed + CHECK(HardMtuFor(1500) == 1558); + } + + TEST_CASE("status codes and OID names are lookable") { + CHECK(StatusName(0xC000'00BBU) == "RNDIS_STATUS_NOT_SUPPORTED"); + CHECK(StatusName(0x4001'000BU) == "RNDIS_STATUS_MEDIA_CONNECT"); + CHECK(StatusName(0xDEAD'BEEFU).empty()); + CHECK(OidName(0x0001'010EU) == "OID_GEN_CURRENT_PACKET_FILTER"); + CHECK(OidName(0xDEAD'BEEFU).empty()); + CHECK(MessageTypeName(0x8000'0002U) == "REMOTE_NDIS_INITIALIZE_CMPLT"); + // The CONDIS message family must be recognized (even if unsupported), not reported as + // "unknown". + CHECK(MessageTypeName(0x0000'8001U) == "RNDIS_MSG_MP_CREATE_VC"); + CHECK(IsCondis(0x0000'8001U)); + CHECK_FALSE(IsCondis(ToRaw(MessageType::kPacket))); + } + + TEST_CASE("distinguishing variable-length and fixed-length OIDs") { + CHECK(IsVariableLengthOid(Oid::kGenSupportedList)); + CHECK(IsVariableLengthOid(Oid::kGenVendorDescription)); + CHECK_FALSE(IsVariableLengthOid(Oid::kEthernetPermanentAddress)); + CHECK_FALSE(IsVariableLengthOid(Oid::kGenCurrentPacketFilter)); + } // --------------------------------------------------------------------------- // INITIALIZE // --------------------------------------------------------------------------- -TEST_CASE("编码 INITIALIZE_MSG") { +TEST_CASE("encode INITIALIZE_MSG") { std::array buffer{}; const auto written = Encode( InitializeRequest{.request_id = 7, .max_transfer_size = 2048}, buffer); @@ -144,12 +146,12 @@ TEST_CASE("编码 INITIALIZE_MSG") { CHECK(LoadLe32(buffer.data() + kInitializeMaxTransferSizeOffset) == 2048); } -TEST_CASE("编码 INITIALIZE_MSG 缓冲不足时报错") { +TEST_CASE("encoding INITIALIZE_MSG reports an error when the buffer is too small") { std::array too_small{}; CHECK_FALSE(Encode(InitializeRequest{}, too_small).has_value()); } -TEST_CASE("解码 INITIALIZE_CMPLT") { +TEST_CASE("decode INITIALIZE_CMPLT") { const auto buffer = MakeInitializeComplete(); const auto complete = DecodeInitializeComplete(buffer); REQUIRE(complete.has_value()); @@ -160,47 +162,47 @@ TEST_CASE("解码 INITIALIZE_CMPLT") { CHECK(complete->max_packets_per_message == 1); } -TEST_CASE("解码 INITIALIZE_CMPLT:设备返回失败状态") { +TEST_CASE("decode INITIALIZE_CMPLT: device returns a failure status") { const auto buffer = MakeInitializeComplete(2048, 1, 0, ToRaw(StatusCode::kFailure)); const auto complete = DecodeInitializeComplete(buffer); REQUIRE_FALSE(complete.has_value()); CHECK(complete.error().Code() == static_cast(ToRaw(StatusCode::kFailure))); - // 错误串里应带上状态码的符号名,便于排障。 + // The error string should carry the symbolic status name for troubleshooting. CHECK(complete.error().ToString().find("RNDIS_STATUS_FAILURE") != std::string::npos); } -TEST_CASE("解码 INITIALIZE_CMPLT:畸形输入一律报错不崩溃") { - SUBCASE("缓冲区太短") { +TEST_CASE("decode INITIALIZE_CMPLT: malformed input always errors without crashing") { + SUBCASE("buffer too short") { std::array tiny{}; StoreLe32(tiny.data(), ToRaw(MessageType::kInitializeComplete)); StoreLe32(tiny.data() + 4, kInitializeCmpltBytes); CHECK_FALSE(DecodeInitializeComplete(tiny).has_value()); } - SUBCASE("消息类型不对") { + SUBCASE("wrong message type") { auto buffer = MakeInitializeComplete(); StoreLe32(buffer.data() + kMessageTypeOffset, ToRaw(MessageType::kQueryComplete)); CHECK_FALSE(DecodeInitializeComplete(buffer).has_value()); } - SUBCASE("MessageLength 超出缓冲") { + SUBCASE("MessageLength exceeds buffer") { auto buffer = MakeInitializeComplete(); StoreLe32(buffer.data() + kMessageLengthOffset, 0xFFFF'FFFFU); CHECK_FALSE(DecodeInitializeComplete(buffer).has_value()); } - SUBCASE("MessageLength 小于最小长度") { + SUBCASE("MessageLength smaller than the minimum") { auto buffer = MakeInitializeComplete(); StoreLe32(buffer.data() + kMessageLengthOffset, 12); CHECK_FALSE(DecodeInitializeComplete(buffer).has_value()); } - SUBCASE("空缓冲") { + SUBCASE("empty buffer") { CHECK_FALSE(DecodeInitializeComplete({}).has_value()); } } // --------------------------------------------------------------------------- -// 协商与设备 quirk 归一化 +// Negotiation and device quirk normalization // --------------------------------------------------------------------------- -TEST_CASE("协商:Android 主线 gadget 的典型参数") { +TEST_CASE("negotiation: typical parameters of the mainline Android gadget") { const auto buffer = MakeInitializeComplete(2048, 1, 0); const auto complete = DecodeInitializeComplete(buffer); REQUIRE(complete.has_value()); @@ -210,10 +212,10 @@ TEST_CASE("协商:Android 主线 gadget 的典型参数") { CHECK(params->mtu == 1500); CHECK(params->device_max_transfer_size == 2048); CHECK(params->max_packets_per_message == 1); - CHECK(params->tx_alignment_bytes == 1); // factor 0 → 对齐 1 字节 + CHECK(params->tx_alignment_bytes == 1); // factor 0 -> 1-byte alignment } -TEST_CASE("协商 quirk:MaxPacketsPerMessage 为 0 时按 1 处理") { +TEST_CASE("negotiation quirk: MaxPacketsPerMessage 0 is treated as 1") { const auto buffer = MakeInitializeComplete(2048, 0, 0); const auto complete = DecodeInitializeComplete(buffer); REQUIRE(complete.has_value()); @@ -222,17 +224,17 @@ TEST_CASE("协商 quirk:MaxPacketsPerMessage 为 0 时按 1 处理") { CHECK(params->max_packets_per_message == 1); } -TEST_CASE("协商 quirk:PacketAlignmentFactor 越界时钳到 7") { +TEST_CASE("negotiation quirk: out-of-range PacketAlignmentFactor is clamped to 7") { const auto buffer = MakeInitializeComplete(2048, 1, 31); const auto complete = DecodeInitializeComplete(buffer); REQUIRE(complete.has_value()); const auto params = Negotiate(*complete, 1500, 65536); REQUIRE(params.has_value()); - // 不钳位的话 1u << 31 会得到荒谬的对齐值。 + // Without clamping, 1u << 31 would yield an absurd alignment value. CHECK(params->tx_alignment_bytes == 128); } -TEST_CASE("协商 quirk:高通聚合补丁报 MaxPacketsPerMessage=3") { +TEST_CASE("negotiation quirk: Qualcomm aggregation patch reports MaxPacketsPerMessage=3") { const auto buffer = MakeInitializeComplete(3 * 1580, 3, 2); const auto complete = DecodeInitializeComplete(buffer); REQUIRE(complete.has_value()); @@ -242,8 +244,9 @@ TEST_CASE("协商 quirk:高通聚合补丁报 MaxPacketsPerMessage=3") { CHECK(params->tx_alignment_bytes == 4); // 1 << 2 } -TEST_CASE("协商 quirk:设备 MaxTransferSize 小于满帧时下调 MTU") { - // 实测案例:HTC Diamond 报 1536,小于 hard_mtu(1558)。 +TEST_CASE( + "negotiation quirk: MTU is lowered when device MaxTransferSize is smaller than a full frame") { + // Measured case: HTC Diamond reports 1536, smaller than hard_mtu(1558). const auto buffer = MakeInitializeComplete(1536, 1, 0); const auto complete = DecodeInitializeComplete(buffer); REQUIRE(complete.has_value()); @@ -252,24 +255,24 @@ TEST_CASE("协商 quirk:设备 MaxTransferSize 小于满帧时下调 MTU") { CHECK(params->mtu == 1536 - kHardHeaderBytes); // 1478 } -TEST_CASE("协商 quirk:WinCE 报 16KB 巨帧时钳到 host 上限") { +TEST_CASE("negotiation quirk: WinCE reports a 16KB giant frame, clamped to the host limit") { const auto buffer = MakeInitializeComplete(16384, 1, 0); const auto complete = DecodeInitializeComplete(buffer); REQUIRE(complete.has_value()); const auto params = Negotiate(*complete, 1500, 4096); REQUIRE(params.has_value()); CHECK(params->device_max_transfer_size == 4096); - CHECK(params->mtu == 1500); // 仍然装得下满帧,MTU 不变 + CHECK(params->mtu == 1500); // Still fits a full frame; MTU unchanged } -TEST_CASE("协商失败:MaxTransferSize 装不下头部") { +TEST_CASE("negotiation fails: MaxTransferSize cannot fit the header") { const auto buffer = MakeInitializeComplete(40, 1, 0); const auto complete = DecodeInitializeComplete(buffer); REQUIRE(complete.has_value()); CHECK_FALSE(Negotiate(*complete, 1500, 65536).has_value()); } -TEST_CASE("协商失败:非以太网介质") { +TEST_CASE("negotiation fails: non-Ethernet medium") { const auto buffer = MakeInitializeComplete(2048, 1, 0, 0, static_cast(Medium::kWirelessLan)); const auto complete = DecodeInitializeComplete(buffer); @@ -277,7 +280,7 @@ TEST_CASE("协商失败:非以太网介质") { CHECK_FALSE(Negotiate(*complete, 1500, 65536).has_value()); } -TEST_CASE("协商失败:面向连接设备") { +TEST_CASE("negotiation fails: connection-oriented device") { const auto buffer = MakeInitializeComplete( 2048, 1, 0, 0, 0, static_cast(DeviceFlags::kConnectionOriented)); const auto complete = DecodeInitializeComplete(buffer); @@ -289,7 +292,8 @@ TEST_CASE("协商失败:面向连接设备") { // QUERY / SET // --------------------------------------------------------------------------- -TEST_CASE("编码 QUERY_MSG:定长 OID 必须带占位长度与尾部零字节") { +TEST_CASE( + "encode QUERY_MSG: fixed-length OIDs must carry a placeholder length and trailing zero bytes") { std::array buffer{}; const auto written = Encode(QueryRequest{.request_id = 3, .oid = Oid::kEthernetPermanentAddress, @@ -300,13 +304,14 @@ TEST_CASE("编码 QUERY_MSG:定长 OID 必须带占位长度与尾部零字节 CHECK(LoadLe32(buffer.data() + kQuerySetOidOffset) == ToRaw(Oid::kEthernetPermanentAddress)); CHECK(LoadLe32(buffer.data() + kQuerySetInfoBufferLengthOffset) == 48); - // 基准点是消息起始 +8,所以内联数据的偏移值是 20 而不是 28。 + // The base point is message start + 8, so the inline data offset value is 20, not 28. CHECK(LoadLe32(buffer.data() + kQuerySetInfoBufferOffsetOffset) == 20); CHECK(LoadLe32(buffer.data() + kQuerySetDeviceVcHandleOffset) == 0); } -TEST_CASE("编码 QUERY_MSG:变长 OID 的长度必须为 0") { - // ActiveSync 的未文档化行为:变长 OID 传非零长度会被拒。 +TEST_CASE("encode QUERY_MSG: variable-length OIDs must have length 0") { + // Undocumented ActiveSync behavior: passing a non-zero length for a variable-length OID is + // rejected. std::array buffer{}; const auto written = Encode(QueryRequest{.request_id = 4, .oid = Oid::kGenSupportedList, @@ -315,11 +320,11 @@ TEST_CASE("编码 QUERY_MSG:变长 OID 的长度必须为 0") { REQUIRE(written.has_value()); CHECK(written.value() == kQuerySetHeaderBytes); CHECK(LoadLe32(buffer.data() + kQuerySetInfoBufferLengthOffset) == 0); - // 长度为 0 时偏移也必须写 0。 + // The offset must also be 0 when the length is 0. CHECK(LoadLe32(buffer.data() + kQuerySetInfoBufferOffsetOffset) == 0); } -TEST_CASE("解码 QUERY_CMPLT:按 +8 基准点定位信息缓冲区") { +TEST_CASE("decode QUERY_CMPLT: locate the information buffer using the +8 base point") { auto buffer = MakeMessage(MessageType::kQueryComplete, kQueryCmpltHeaderBytes + 6); StoreLe32(buffer.data() + kQueryCmpltRequestIdOffset, 9); StoreLe32(buffer.data() + kQueryCmpltStatusOffset, 0); @@ -340,9 +345,9 @@ TEST_CASE("解码 QUERY_CMPLT:按 +8 基准点定位信息缓冲区") { CHECK(*parsed == MacAddress{0x02, 0x11, 0x22, 0x33, 0x44, 0x55}); } -TEST_CASE("解码 QUERY_CMPLT:设备返回不支持时不算致命错误") { - // 可选 OID(如 OID_GEN_PHYSICAL_MEDIUM)返回 NOT_SUPPORTED 是正常情况, - // 解码必须成功返回并把状态交给调用方判断。 +TEST_CASE("decode QUERY_CMPLT: device returning not-supported is not a fatal error") { + // Returning NOT_SUPPORTED for an optional OID (e.g. OID_GEN_PHYSICAL_MEDIUM) is normal; + // decoding must succeed and hand the status to the caller. auto buffer = MakeMessage(MessageType::kQueryComplete, kQueryCmpltHeaderBytes); StoreLe32(buffer.data() + kQueryCmpltStatusOffset, ToRaw(StatusCode::kNotSupported)); const auto complete = DecodeQueryComplete(buffer); @@ -351,15 +356,15 @@ TEST_CASE("解码 QUERY_CMPLT:设备返回不支持时不算致命错误") { CHECK(complete->information.empty()); } -TEST_CASE("解码 QUERY_CMPLT:信息缓冲区越界必须报错") { +TEST_CASE("decode QUERY_CMPLT: out-of-bounds information buffer must error") { auto buffer = MakeMessage(MessageType::kQueryComplete, kQueryCmpltHeaderBytes + 4); StoreLe32(buffer.data() + kQueryCmpltStatusOffset, 0); - StoreLe32(buffer.data() + kQueryCmpltInfoBufferLengthOffset, 1024); // 撒谎 + StoreLe32(buffer.data() + kQueryCmpltInfoBufferLengthOffset, 1024); // lying StoreLe32(buffer.data() + kQueryCmpltInfoBufferOffsetOffset, kQueryCmpltInlineInfoOffset); CHECK_FALSE(DecodeQueryComplete(buffer).has_value()); } -TEST_CASE("编码 SET_MSG 与 SET 一个 LE32") { +TEST_CASE("encode SET_MSG and set one LE32") { std::array buffer{}; const auto written = EncodeSetUint32(11, Oid::kGenCurrentPacketFilter, kDefaultPacketFilter, buffer); @@ -372,7 +377,7 @@ TEST_CASE("编码 SET_MSG 与 SET 一个 LE32") { CHECK(LoadLe32(buffer.data() + kQuerySetHeaderBytes) == kDefaultPacketFilter); } -TEST_CASE("解码 SET_CMPLT") { +TEST_CASE("decode SET_CMPLT") { auto buffer = MakeMessage(MessageType::kSetComplete, kSetCmpltBytes); StoreLe32(buffer.data() + kSetCmpltRequestIdOffset, 11); StoreLe32(buffer.data() + kSetCmpltStatusOffset, 0); @@ -383,12 +388,12 @@ TEST_CASE("解码 SET_CMPLT") { } // --------------------------------------------------------------------------- -// RESET —— 字段偏移与其他消息不同 +// RESET -- field offsets differ from other messages // --------------------------------------------------------------------------- -TEST_CASE("编码 RESET_MSG:offset 8 是 Reserved 而非 RequestId") { +TEST_CASE("encode RESET_MSG: offset 8 is Reserved, not RequestId") { std::array buffer{}; - buffer[kResetReservedOffset] = std::byte{0xFF}; // 先污染,验证被清零 + buffer[kResetReservedOffset] = std::byte{0xFF}; // Pre-fill to verify it gets zeroed const auto written = EncodeReset(buffer); REQUIRE(written.has_value()); CHECK(written.value() == kResetMsgBytes); @@ -397,7 +402,7 @@ TEST_CASE("编码 RESET_MSG:offset 8 是 Reserved 而非 RequestId") { CHECK(LoadLe32(buffer.data() + kResetReservedOffset) == 0); } -TEST_CASE("解码 RESET_CMPLT:Status 在 offset 8 而不是 12") { +TEST_CASE("decode RESET_CMPLT: Status is at offset 8, not 12") { auto buffer = MakeMessage(MessageType::kResetComplete, kResetCmpltBytes); StoreLe32(buffer.data() + kResetCmpltStatusOffset, 0); StoreLe32(buffer.data() + kResetCmpltAddressingResetOffset, 1); @@ -405,11 +410,11 @@ TEST_CASE("解码 RESET_CMPLT:Status 在 offset 8 而不是 12") { const auto complete = DecodeResetComplete(buffer); REQUIRE(complete.has_value()); CHECK(complete->status == 0); - // AddressingReset 非零 → host 必须重发 SET OID_GEN_CURRENT_PACKET_FILTER。 + // Non-zero AddressingReset -> the host must re-send SET OID_GEN_CURRENT_PACKET_FILTER. CHECK(complete->addressing_reset); } -TEST_CASE("解码 RESET_CMPLT:AddressingReset 为 0 时无需重放") { +TEST_CASE("decode RESET_CMPLT: no replay needed when AddressingReset is 0") { auto buffer = MakeMessage(MessageType::kResetComplete, kResetCmpltBytes); StoreLe32(buffer.data() + kResetCmpltAddressingResetOffset, 0); const auto complete = DecodeResetComplete(buffer); @@ -421,7 +426,7 @@ TEST_CASE("解码 RESET_CMPLT:AddressingReset 为 0 时无需重放") { // KEEPALIVE // --------------------------------------------------------------------------- -TEST_CASE("KEEPALIVE 往返") { +TEST_CASE("KEEPALIVE round trip") { std::array request{}; const auto written = EncodeKeepAlive(42, request); REQUIRE(written.has_value()); @@ -435,7 +440,7 @@ TEST_CASE("KEEPALIVE 往返") { CHECK(complete->request_id == 42); } -TEST_CASE("host 也要能编码 KEEPALIVE_CMPLT(设备可主动发起保活)") { +TEST_CASE("host must also be able to encode KEEPALIVE_CMPLT (device may initiate keep-alive)") { std::array buffer{}; const auto written = EncodeKeepAliveComplete(77, ToRaw(StatusCode::kSuccess), buffer); REQUIRE(written.has_value()); @@ -446,10 +451,10 @@ TEST_CASE("host 也要能编码 KEEPALIVE_CMPLT(设备可主动发起保活) } // --------------------------------------------------------------------------- -// INDICATE_STATUS —— 基准点矛盾的容错处理 +// INDICATE_STATUS -- tolerant handling of the contradictory base point // --------------------------------------------------------------------------- -TEST_CASE("解码 INDICATE_STATUS:MEDIA_CONNECT 无负载") { +TEST_CASE("decode INDICATE_STATUS: MEDIA_CONNECT with no payload") { auto buffer = MakeMessage(MessageType::kIndicateStatus, kIndicateStatusHeaderBytes); StoreLe32(buffer.data() + kIndicateStatusStatusOffset, ToRaw(StatusCode::kMediaConnect)); StoreLe32(buffer.data() + kIndicateStatusBufferLengthOffset, 0); @@ -461,12 +466,12 @@ TEST_CASE("解码 INDICATE_STATUS:MEDIA_CONNECT 无负载") { CHECK_FALSE(indication->has_diagnostic_info); } -TEST_CASE("解码 INDICATE_STATUS:按 +8 基准点解释状态缓冲区") { +TEST_CASE("decode INDICATE_STATUS: interpret status buffer using the +8 base point") { const std::uint32_t total = kIndicateStatusHeaderBytes + kDiagnosticInfoBytes; auto buffer = MakeMessage(MessageType::kIndicateStatus, total); StoreLe32(buffer.data() + kIndicateStatusStatusOffset, ToRaw(StatusCode::kInvalidData)); StoreLe32(buffer.data() + kIndicateStatusBufferLengthOffset, kDiagnosticInfoBytes); - // +8 基准点:20 - 8 = 12 + // +8 base point: 20 - 8 = 12 StoreLe32(buffer.data() + kIndicateStatusBufferOffsetOffset, kIndicateStatusHeaderBytes - kOffsetFieldBase); StoreLe32(buffer.data() + kIndicateStatusHeaderBytes + kDiagnosticInfoDiagStatusOffset, @@ -481,29 +486,30 @@ TEST_CASE("解码 INDICATE_STATUS:按 +8 基准点解释状态缓冲区") { CHECK(indication->diagnostic_error_offset == 36); } -TEST_CASE("解码 INDICATE_STATUS:按消息起始基准点解释也能工作") { - // 某些设备按 MS 文档字面实现(基准点 = 消息起始)。 +TEST_CASE("decode INDICATE_STATUS: interpreting with the message-start base point also works") { + // Some devices implement the MS spec literally (base point = message start). const std::uint32_t total = kIndicateStatusHeaderBytes + kDiagnosticInfoBytes; auto buffer = MakeMessage(MessageType::kIndicateStatus, total); StoreLe32(buffer.data() + kIndicateStatusStatusOffset, ToRaw(StatusCode::kInvalidData)); StoreLe32(buffer.data() + kIndicateStatusBufferLengthOffset, kDiagnosticInfoBytes); - // 基准点 = 消息起始:直接填 20 + // Base point = message start: write 20 directly StoreLe32(buffer.data() + kIndicateStatusBufferOffsetOffset, kIndicateStatusHeaderBytes); StoreLe32(buffer.data() + kIndicateStatusHeaderBytes + kDiagnosticInfoDiagStatusOffset, 0xAAU); const auto indication = DecodeIndicateStatus(buffer); REQUIRE(indication.has_value()); - // +8 解释会越界(8+20+8=36 > 28),因此回退到字面解释。 + // The +8 interpretation would go out of bounds (8+20+8=36 > 28), so it falls back to the + // literal interpretation. REQUIRE(indication->status_buffer.size() == kDiagnosticInfoBytes); CHECK(indication->diagnostic_status == 0xAAU); } -TEST_CASE("解码 INDICATE_STATUS:两种基准点都越界时仍返回成功") { - // 关键行为:status 本身有用(可能就是 MEDIA_DISCONNECT), - // 不能因为一个可选负载解析不了就把链路判死。 +TEST_CASE("decode INDICATE_STATUS: still succeeds when both base points are out of bounds") { + // Key behavior: the status itself is useful (it may be MEDIA_DISCONNECT), so we must not + // declare the link dead just because an optional payload cannot be parsed. auto buffer = MakeMessage(MessageType::kIndicateStatus, kIndicateStatusHeaderBytes); StoreLe32(buffer.data() + kIndicateStatusStatusOffset, ToRaw(StatusCode::kMediaDisconnect)); - StoreLe32(buffer.data() + kIndicateStatusBufferLengthOffset, 4096); // 撒谎 + StoreLe32(buffer.data() + kIndicateStatusBufferLengthOffset, 4096); // lying StoreLe32(buffer.data() + kIndicateStatusBufferOffsetOffset, 12); const auto indication = DecodeIndicateStatus(buffer); @@ -512,13 +518,13 @@ TEST_CASE("解码 INDICATE_STATUS:两种基准点都越界时仍返回成功") CHECK(indication->status_buffer.empty()); } -TEST_CASE("解码 INDICATE_STATUS:类型不对时报错") { +TEST_CASE("decode INDICATE_STATUS: wrong type errors") { auto buffer = MakeMessage(MessageType::kQueryComplete, kIndicateStatusHeaderBytes); CHECK_FALSE(DecodeIndicateStatus(buffer).has_value()); } // --------------------------------------------------------------------------- -// OID 负载解析 +// OID payload parsing // --------------------------------------------------------------------------- TEST_CASE("ParseUint32 / ParseCounter / ParseMac") { @@ -529,44 +535,44 @@ TEST_CASE("ParseUint32 / ParseCounter / ParseMac") { REQUIRE(value.has_value()); CHECK(*value == 0x0000'002DU); - SUBCASE("计数器 OID 返回 4 字节") { + SUBCASE("counter OID returns 4 bytes") { const auto counter = ParseCounter(std::span{payload}.first(4)); REQUIRE(counter.has_value()); CHECK(*counter == 0x0000'002DU); } - SUBCASE("计数器 OID 返回 8 字节") { + SUBCASE("counter OID returns 8 bytes") { StoreLe64(payload.data(), 0x0000'0001'0000'002DULL); const auto counter = ParseCounter(payload); REQUIRE(counter.has_value()); CHECK(*counter == 0x0000'0001'0000'002DULL); } - SUBCASE("计数器 OID 返回 2 字节应报错") { + SUBCASE("counter OID returning 2 bytes must error") { CHECK_FALSE(ParseCounter(std::span{payload}.first(2)).has_value()); } - SUBCASE("MAC 长度不足应报错") { + SUBCASE("insufficient MAC length must error") { CHECK_FALSE(ParseMac(std::span{payload}.first(4)).has_value()); } } -TEST_CASE("FormatMac 输出规范格式") { +TEST_CASE("FormatMac outputs the canonical format") { const MacAddress mac{0x02, 0x00, 0xAB, 0xCD, 0xEF, 0x01}; const auto text = FormatMac(mac); CHECK(std::string_view{text.data()} == "02:00:ab:cd:ef:01"); } // --------------------------------------------------------------------------- -// USB 描述符签名识别 +// USB descriptor signature recognition // --------------------------------------------------------------------------- -TEST_CASE("识别四种已知的 RNDIS 通信类接口签名") { - CHECK(IsRndisControlSignature({0x02, 0x02, 0xFF})); // 标准 MS RNDIS +TEST_CASE("recognize the four known RNDIS communication-class interface signatures") { + CHECK(IsRndisControlSignature({0x02, 0x02, 0xFF})); // standard MS RNDIS CHECK(IsRndisControlSignature({0xEF, 0x01, 0x01})); // ActiveSync - CHECK(IsRndisControlSignature({0xE0, 0x01, 0x03})); // 无线 RNDIS(手机共享) - CHECK(IsRndisControlSignature({0xEF, 0x04, 0x01})); // Novatel 变体 + CHECK(IsRndisControlSignature({0xE0, 0x01, 0x03})); // wireless RNDIS (phone tethering) + CHECK(IsRndisControlSignature({0xEF, 0x04, 0x01})); // Novatel variant - // 真 CDC ACM 调制解调器(protocol=0x01)不能误判成 RNDIS。 + // A real CDC ACM modem (protocol=0x01) must not be mistaken for RNDIS. CHECK_FALSE(IsRndisControlSignature({0x02, 0x02, 0x01})); - // CDC ECM 也不是 RNDIS。 + // CDC ECM is not RNDIS either. CHECK_FALSE(IsRndisControlSignature({0x02, 0x06, 0x00})); CHECK(kDataSignature == InterfaceSignature{0x0A, 0x00, 0x00}); } diff --git a/tests/test_rndis_packet_codec.cc b/tests/test_rndis_packet_codec.cc index 960cf34..c9679d8 100644 --- a/tests/test_rndis_packet_codec.cc +++ b/tests/test_rndis_packet_codec.cc @@ -1,12 +1,18 @@ -// REMOTE_NDIS_PACKET_MSG 编解码的单元测试 —— 数据热路径的正确性防线。 +// Unit tests for the REMOTE_NDIS_PACKET_MSG codec -- the correctness +// lifeline of the data hot path. // -// 重点覆盖: -// 1. DataOffset 基准点是消息起始 +8(编码写 36,解码按 8+36 定位); -// 2. 多包聚合的对齐规则:填充归入**上一个**消息的 MessageLength, -// 最后一个消息不含外部填充; -// 3. ZLP 规避:传输长度恰为端点最大包长整数倍时补 1 字节; -// 4. 解码必须容忍尾部填充(否则每个满传输都会误报一次); -// 5. 全部畸形输入都被识别为 kMalformed 且不读越界。 +// The focus is on: +// 1. The DataOffset base point is the message start +8 (the encoder writes +// 36, the decoder locates at 8+36); +// 2. The alignment rule for multi-packet aggregation: the padding belongs +// to the **previous** message's MessageLength, and the last message +// carries no trailing padding; +// 3. ZLP avoidance: when the transfer length is exactly a multiple of the +// endpoint's maximum packet size, one byte is added; +// 4. The decoder must tolerate trailing padding (otherwise every full +// transfer reports a spurious error); +// 5. All malformed inputs are recognized as kMalformed without reading out +// of bounds. #include #include #include @@ -28,7 +34,7 @@ namespace { constexpr std::uint32_t kMaxFrame = 2048; -/// 造一帧内容可自校验的以太帧。 +/// Builds an Ethernet frame whose content can be self-verified. std::vector MakeFrame(std::uint32_t length, std::uint8_t tag) { std::vector frame(length); for (std::uint32_t i = 0; i < length; ++i) { @@ -49,7 +55,8 @@ bool FrameMatches(std::span frame, std::uint32_t length, std::u return true; } -/// 手工构造一个 PACKET_MSG(用于测解码器,不依赖编码器)。 +/// Hand-builds a PACKET_MSG (for testing the decoder, independent of the +/// encoder). void WritePacketMessage(std::span out, std::span frame, std::uint32_t data_offset_field, std::uint32_t message_length, std::uint32_t data_length_field) { @@ -66,7 +73,8 @@ void WritePacketMessage(std::span out, std::span fra } } -/// 收集一次传输里解出的所有帧(拷贝出来,便于断言)。 +/// Collects every frame decoded out of one transfer (copied out, for +/// assertions). struct DecodeSummary { std::vector> frames; ReadOutcome final_outcome = ReadOutcome::kEndOfTransfer; @@ -95,339 +103,341 @@ DecodeSummary DecodeAll(std::span transfer, } // namespace TEST_SUITE("rndis.packet_codec") { + // --------------------------------------------------------------------------- + // Encoding + // --------------------------------------------------------------------------- + + TEST_CASE("Encoding a single frame: DataOffset must be written as 36") { + std::array transfer{}; + PacketMessageWriter writer( + transfer, {.max_transfer_bytes = 2048, .max_messages = 1, .alignment_bytes = 1}, 512); + + const auto frame = MakeFrame(1514, 0x11); + REQUIRE(writer.TryAppend(frame)); + const std::uint32_t total = writer.Finish(); + + CHECK(writer.MessageCount() == 1); + CHECK(writer.PayloadBytes() == 1514); + CHECK(total == kPacketMsgHeaderBytes + 1514); + + CHECK(LoadLe32(transfer.data() + kMessageTypeOffset) == ToRaw(MessageType::kPacket)); + CHECK(LoadLe32(transfer.data() + kMessageLengthOffset) == kPacketMsgHeaderBytes + 1514); + // The key assertion: 36, not 44. + CHECK(LoadLe32(transfer.data() + kPacketDataOffsetOffset) == 36); + CHECK(LoadLe32(transfer.data() + kPacketDataLengthOffset) == 1514); + // OOB / per-packet info / VcHandle / Reserved must all be zero. + CHECK(LoadLe32(transfer.data() + kPacketOobDataOffsetOffset) == 0); + CHECK(LoadLe32(transfer.data() + kPacketOobDataLengthOffset) == 0); + CHECK(LoadLe32(transfer.data() + kPacketNumOobDataElementsOffset) == 0); + CHECK(LoadLe32(transfer.data() + kPacketPerPacketInfoOffsetOffset) == 0); + CHECK(LoadLe32(transfer.data() + kPacketPerPacketInfoLengthOffset) == 0); + CHECK(LoadLe32(transfer.data() + kPacketVcHandleOffset) == 0); + CHECK(LoadLe32(transfer.data() + kPacketReservedOffset) == 0); + + // The frame data lands at absolute offset 8 + 36 = 44. + CHECK(FrameMatches(std::span{transfer}.subspan(44, 1514), 1514, 0x11)); + } -// --------------------------------------------------------------------------- -// 编码 -// --------------------------------------------------------------------------- - -TEST_CASE("编码单帧:DataOffset 必须写 36") { - std::array transfer{}; - PacketMessageWriter writer(transfer, - {.max_transfer_bytes = 2048, .max_messages = 1, .alignment_bytes = 1}, - 512); - - const auto frame = MakeFrame(1514, 0x11); - REQUIRE(writer.TryAppend(frame)); - const std::uint32_t total = writer.Finish(); - - CHECK(writer.MessageCount() == 1); - CHECK(writer.PayloadBytes() == 1514); - CHECK(total == kPacketMsgHeaderBytes + 1514); - - CHECK(LoadLe32(transfer.data() + kMessageTypeOffset) == ToRaw(MessageType::kPacket)); - CHECK(LoadLe32(transfer.data() + kMessageLengthOffset) == kPacketMsgHeaderBytes + 1514); - // ★ 关键断言:36,不是 44。 - CHECK(LoadLe32(transfer.data() + kPacketDataOffsetOffset) == 36); - CHECK(LoadLe32(transfer.data() + kPacketDataLengthOffset) == 1514); - // OOB / per-packet info / VcHandle / Reserved 必须全零。 - CHECK(LoadLe32(transfer.data() + kPacketOobDataOffsetOffset) == 0); - CHECK(LoadLe32(transfer.data() + kPacketOobDataLengthOffset) == 0); - CHECK(LoadLe32(transfer.data() + kPacketNumOobDataElementsOffset) == 0); - CHECK(LoadLe32(transfer.data() + kPacketPerPacketInfoOffsetOffset) == 0); - CHECK(LoadLe32(transfer.data() + kPacketPerPacketInfoLengthOffset) == 0); - CHECK(LoadLe32(transfer.data() + kPacketVcHandleOffset) == 0); - CHECK(LoadLe32(transfer.data() + kPacketReservedOffset) == 0); - - // 帧数据落在绝对偏移 8 + 36 = 44 处。 - CHECK(FrameMatches(std::span{transfer}.subspan(44, 1514), 1514, 0x11)); -} - -TEST_CASE("编码受 MaxPacketsPerMessage 限制") { - std::array transfer{}; - PacketMessageWriter writer(transfer, - {.max_transfer_bytes = 8192, .max_messages = 2, .alignment_bytes = 1}, - 512); - CHECK(writer.TryAppend(MakeFrame(64, 1))); - CHECK(writer.TryAppend(MakeFrame(64, 2))); - CHECK_FALSE(writer.TryAppend(MakeFrame(64, 3))); // 超出包数上限 - CHECK(writer.MessageCount() == 2); -} - -TEST_CASE("编码受设备 MaxTransferSize 限制") { - std::array transfer{}; - // 只够放一个 44+64=108 字节的消息(再留 1 字节 ZLP 余量)。 - PacketMessageWriter writer(transfer, - {.max_transfer_bytes = 150, .max_messages = 10, .alignment_bytes = 1}, - 512); - CHECK(writer.TryAppend(MakeFrame(64, 1))); - CHECK_FALSE(writer.TryAppend(MakeFrame(64, 2))); - CHECK(writer.MessageCount() == 1); -} - -TEST_CASE("编码空批次 Finish 返回 0") { - std::array transfer{}; - PacketMessageWriter writer(transfer, {.max_transfer_bytes = 1024}, 512); - CHECK(writer.Empty()); - CHECK(writer.Finish() == 0); -} - -TEST_CASE("多包聚合:填充归入上一个消息,最后一个不含外部填充") { - std::array transfer{}; - // 对齐 8 字节(PacketAlignmentFactor = 3)。 - PacketMessageWriter writer(transfer, - {.max_transfer_bytes = 8192, .max_messages = 3, .alignment_bytes = 8}, - 512); - - // 帧长 61 → 消息长 44+61 = 105。105 不是 8 的倍数,向上对齐到 112(14×8), - // 所以下一个消息前需要 7 字节填充,且这 7 字节要被吞进**第一个**消息的 - // MessageLength。 - REQUIRE(writer.TryAppend(MakeFrame(61, 0xA0))); - REQUIRE(writer.TryAppend(MakeFrame(70, 0xB0))); - REQUIRE(writer.TryAppend(MakeFrame(64, 0xC0))); - const std::uint32_t total = writer.Finish(); - - // 第一个消息:105 → 被扩到 112(吞掉 7 字节填充)。 - CHECK(LoadLe32(transfer.data() + kMessageLengthOffset) == 112); - CHECK(112 % 8 == 0); - - // 第二个消息从 112 开始:44+70 = 114 → 累计 226,向上对齐到 232, - // 于是第二个消息的 MessageLength 被扩到 114 + 6 = 120。 - CHECK(LoadLe32(transfer.data() + 112 + kMessageLengthOffset) == 120); - CHECK((112 + 120) % 8 == 0); - - // 第三个(最后一个)消息从 232 开始:44+64 = 108,**不含**外部填充。 - CHECK(LoadLe32(transfer.data() + 232 + kMessageLengthOffset) == 108); - CHECK(total == 232 + 108); - CHECK(writer.PayloadBytes() == 61 + 70 + 64); -} + TEST_CASE("Encoding is limited by MaxPacketsPerMessage") { + std::array transfer{}; + PacketMessageWriter writer( + transfer, {.max_transfer_bytes = 8192, .max_messages = 2, .alignment_bytes = 1}, 512); + CHECK(writer.TryAppend(MakeFrame(64, 1))); + CHECK(writer.TryAppend(MakeFrame(64, 2))); + CHECK_FALSE(writer.TryAppend(MakeFrame(64, 3))); // over the message count limit + CHECK(writer.MessageCount() == 2); + } -TEST_CASE("多包聚合:编码结果能被解码器完整还原") { - std::array transfer{}; - PacketMessageWriter writer(transfer, - {.max_transfer_bytes = 8192, .max_messages = 4, .alignment_bytes = 8}, - 512); - const std::array lengths{61, 70, 1514, 14}; - const std::array tags{0xA0, 0xB0, 0xC0, 0xD0}; - for (std::size_t i = 0; i < lengths.size(); ++i) { - REQUIRE(writer.TryAppend(MakeFrame(lengths[i], tags[i]))); + TEST_CASE("Encoding is limited by the device's MaxTransferSize") { + std::array transfer{}; + // Room for exactly one 44+64=108 byte message (plus 1 byte of ZLP margin). + PacketMessageWriter writer( + transfer, {.max_transfer_bytes = 150, .max_messages = 10, .alignment_bytes = 1}, 512); + CHECK(writer.TryAppend(MakeFrame(64, 1))); + CHECK_FALSE(writer.TryAppend(MakeFrame(64, 2))); + CHECK(writer.MessageCount() == 1); } - const std::uint32_t total = writer.Finish(); - const auto summary = DecodeAll(std::span{transfer}.first(total)); - CHECK(summary.final_outcome == ReadOutcome::kEndOfTransfer); - REQUIRE(summary.frames.size() == 4); - for (std::size_t i = 0; i < lengths.size(); ++i) { - CHECK(FrameMatches(summary.frames[i], lengths[i], tags[i])); + TEST_CASE("Encoding an empty batch: Finish returns 0") { + std::array transfer{}; + PacketMessageWriter writer(transfer, {.max_transfer_bytes = 1024}, 512); + CHECK(writer.Empty()); + CHECK(writer.Finish() == 0); } -} -TEST_CASE("ZLP 规避:长度恰为端点最大包长整数倍时补 1 字节") { - std::array transfer{}; - constexpr std::uint32_t kEndpointMaxPacket = 512; - // 让总长恰好是 512:44 + 468 = 512。 - PacketMessageWriter writer( - transfer, {.max_transfer_bytes = 4096, .max_messages = 1, .alignment_bytes = 1}, - kEndpointMaxPacket); - REQUIRE(writer.TryAppend(MakeFrame(468, 0x55))); - const std::uint32_t total = writer.Finish(); - - CHECK(writer.ZlpPaddingAdded()); - CHECK(total == 513); - CHECK(total % kEndpointMaxPacket != 0); - // 补的那个字节必须是 0,且在 MessageLength(512)之外。 - CHECK(transfer[512] == std::byte{0}); - CHECK(LoadLe32(transfer.data() + kMessageLengthOffset) == 512); -} + TEST_CASE( + "Multi-packet aggregation: the padding goes to the previous message, the last has none") { + std::array transfer{}; + // 8-byte alignment (PacketAlignmentFactor = 3). + PacketMessageWriter writer( + transfer, {.max_transfer_bytes = 8192, .max_messages = 3, .alignment_bytes = 8}, 512); + + // Frame length 61 -> message length 44+61 = 105. 105 is not a multiple of + // 8, so it is aligned up to 112 (14x8); that means 7 bytes of padding + // before the next message, and those 7 bytes are swallowed into the + // MessageLength of the **first** message. + REQUIRE(writer.TryAppend(MakeFrame(61, 0xA0))); + REQUIRE(writer.TryAppend(MakeFrame(70, 0xB0))); + REQUIRE(writer.TryAppend(MakeFrame(64, 0xC0))); + const std::uint32_t total = writer.Finish(); + + // First message: 105 -> widened to 112 (absorbing the 7 padding bytes). + CHECK(LoadLe32(transfer.data() + kMessageLengthOffset) == 112); + CHECK(112 % 8 == 0); + + // The second message starts at 112: 44+70 = 114 -> cumulative 226, aligned + // up to 232, so the second message's MessageLength is widened to 114 + 6 = + // 120. + CHECK(LoadLe32(transfer.data() + 112 + kMessageLengthOffset) == 120); + CHECK((112 + 120) % 8 == 0); + + // The third (last) message starts at 232: 44+64 = 108, with **no** trailing + // padding. + CHECK(LoadLe32(transfer.data() + 232 + kMessageLengthOffset) == 108); + CHECK(total == 232 + 108); + CHECK(writer.PayloadBytes() == 61 + 70 + 64); + } -TEST_CASE("ZLP 规避:长度不是整数倍时不补字节") { - std::array transfer{}; - PacketMessageWriter writer(transfer, - {.max_transfer_bytes = 4096, .max_messages = 1, .alignment_bytes = 1}, - 512); - REQUIRE(writer.TryAppend(MakeFrame(100, 0x66))); - const std::uint32_t total = writer.Finish(); - CHECK_FALSE(writer.ZlpPaddingAdded()); - CHECK(total == kPacketMsgHeaderBytes + 100); -} + TEST_CASE("Multi-packet aggregation: the encoder's output is fully reproduced by the decoder") { + std::array transfer{}; + PacketMessageWriter writer( + transfer, {.max_transfer_bytes = 8192, .max_messages = 4, .alignment_bytes = 8}, 512); + const std::array lengths{61, 70, 1514, 14}; + const std::array tags{0xA0, 0xB0, 0xC0, 0xD0}; + for (std::size_t i = 0; i < lengths.size(); ++i) { + REQUIRE(writer.TryAppend(MakeFrame(lengths[i], tags[i]))); + } + const std::uint32_t total = writer.Finish(); -TEST_CASE("RemainingFrameCapacity 报告准确,可避免取帧后回退") { - std::array transfer{}; - PacketMessageWriter writer(transfer, - {.max_transfer_bytes = 200, .max_messages = 5, .alignment_bytes = 1}, - 512); - // 容量 200 - 1(ZLP 余量)= 199;减去 44 字节头 → 155。 - CHECK(writer.RemainingFrameCapacity() == 155); - REQUIRE(writer.TryAppend(MakeFrame(100, 1))); - // 已用 144;剩 199 - 144 - 44 = 11。 - CHECK(writer.RemainingFrameCapacity() == 11); - // 报告说只能再放 11 字节,那么放 12 字节必须失败。 - CHECK_FALSE(writer.TryAppend(MakeFrame(12, 2))); - CHECK(writer.TryAppend(MakeFrame(11, 2))); - CHECK(writer.RemainingFrameCapacity() == 0); -} + const auto summary = DecodeAll(std::span{transfer}.first(total)); + CHECK(summary.final_outcome == ReadOutcome::kEndOfTransfer); + REQUIRE(summary.frames.size() == 4); + for (std::size_t i = 0; i < lengths.size(); ++i) { + CHECK(FrameMatches(summary.frames[i], lengths[i], tags[i])); + } + } -TEST_CASE("包数用满后 RemainingFrameCapacity 归零") { - std::array transfer{}; - PacketMessageWriter writer(transfer, - {.max_transfer_bytes = 8192, .max_messages = 1, .alignment_bytes = 1}, - 512); - REQUIRE(writer.TryAppend(MakeFrame(64, 1))); - CHECK(writer.RemainingFrameCapacity() == 0); -} + TEST_CASE( + "ZLP avoidance: one byte is added when the length is a multiple of the max packet size") { + std::array transfer{}; + constexpr std::uint32_t kEndpointMaxPacket = 512; + // Make the total exactly 512: 44 + 468 = 512. + PacketMessageWriter writer( + transfer, {.max_transfer_bytes = 4096, .max_messages = 1, .alignment_bytes = 1}, + kEndpointMaxPacket); + REQUIRE(writer.TryAppend(MakeFrame(468, 0x55))); + const std::uint32_t total = writer.Finish(); + + CHECK(writer.ZlpPaddingAdded()); + CHECK(total == 513); + CHECK(total % kEndpointMaxPacket != 0); + // The added byte must be 0, and it must be outside the MessageLength (512). + CHECK(transfer[512] == std::byte{0}); + CHECK(LoadLe32(transfer.data() + kMessageLengthOffset) == 512); + } -// --------------------------------------------------------------------------- -// 解码 -// --------------------------------------------------------------------------- + TEST_CASE("ZLP avoidance: no byte is added when the length is not a multiple") { + std::array transfer{}; + PacketMessageWriter writer( + transfer, {.max_transfer_bytes = 4096, .max_messages = 1, .alignment_bytes = 1}, 512); + REQUIRE(writer.TryAppend(MakeFrame(100, 0x66))); + const std::uint32_t total = writer.Finish(); + CHECK_FALSE(writer.ZlpPaddingAdded()); + CHECK(total == kPacketMsgHeaderBytes + 100); + } -TEST_CASE("解码单帧:按 8 + DataOffset 定位数据") { - std::vector transfer(kPacketMsgHeaderBytes + 1514); - const auto frame = MakeFrame(1514, 0x22); - WritePacketMessage(transfer, frame, kPacketInlineDataOffset, - kPacketMsgHeaderBytes + 1514, 1514); + TEST_CASE("RemainingFrameCapacity reports accurately, which avoids backtracking after a frame") { + std::array transfer{}; + PacketMessageWriter writer( + transfer, {.max_transfer_bytes = 200, .max_messages = 5, .alignment_bytes = 1}, 512); + // Capacity 200 - 1 (ZLP margin) = 199; minus the 44-byte header -> 155. + CHECK(writer.RemainingFrameCapacity() == 155); + REQUIRE(writer.TryAppend(MakeFrame(100, 1))); + // 144 used so far; 199 - 144 - 44 = 11 left. + CHECK(writer.RemainingFrameCapacity() == 11); + // The report says only 11 more bytes fit, so 12 bytes must fail. + CHECK_FALSE(writer.TryAppend(MakeFrame(12, 2))); + CHECK(writer.TryAppend(MakeFrame(11, 2))); + CHECK(writer.RemainingFrameCapacity() == 0); + } - const auto summary = DecodeAll(transfer); - CHECK(summary.final_outcome == ReadOutcome::kEndOfTransfer); - REQUIRE(summary.frames.size() == 1); - CHECK(FrameMatches(summary.frames[0], 1514, 0x22)); -} + TEST_CASE("RemainingFrameCapacity goes to zero once the message count is used up") { + std::array transfer{}; + PacketMessageWriter writer( + transfer, {.max_transfer_bytes = 8192, .max_messages = 1, .alignment_bytes = 1}, 512); + REQUIRE(writer.TryAppend(MakeFrame(64, 1))); + CHECK(writer.RemainingFrameCapacity() == 0); + } -TEST_CASE("解码:数据不紧跟头部(DataOffset 大于 36)") { - // 设备完全可以在头部与数据之间留空隙,解码器必须按 DataOffset 走。 - constexpr std::uint32_t kGap = 16; - constexpr std::uint32_t kFrameLength = 100; - const std::uint32_t message_length = kPacketMsgHeaderBytes + kGap + kFrameLength; - std::vector transfer(message_length); - const auto frame = MakeFrame(kFrameLength, 0x33); - WritePacketMessage(transfer, frame, kPacketInlineDataOffset + kGap, message_length, kFrameLength); - - const auto summary = DecodeAll(transfer); - REQUIRE(summary.frames.size() == 1); - CHECK(FrameMatches(summary.frames[0], kFrameLength, 0x33)); -} + // --------------------------------------------------------------------------- + // Decoding + // --------------------------------------------------------------------------- -TEST_CASE("解码:容忍尾部 1 字节 ZLP 规避填充") { - // 这是必须的容忍 —— 否则每个满传输都会误报一次畸形。 - std::vector transfer(kPacketMsgHeaderBytes + 64 + 1); - WritePacketMessage(transfer, MakeFrame(64, 0x44), kPacketInlineDataOffset, - kPacketMsgHeaderBytes + 64, 64); - - const auto summary = DecodeAll(transfer); - CHECK(summary.final_outcome == ReadOutcome::kEndOfTransfer); - CHECK(summary.reason == MalformedReason::kNone); - REQUIRE(summary.frames.size() == 1); - CHECK(summary.trailing_padding == 1); -} + TEST_CASE("Decoding a single frame: the data is located by 8 + DataOffset") { + std::vector transfer(kPacketMsgHeaderBytes + 1514); + const auto frame = MakeFrame(1514, 0x22); + WritePacketMessage(transfer, frame, kPacketInlineDataOffset, kPacketMsgHeaderBytes + 1514, + 1514); -TEST_CASE("解码:容忍最多 43 字节的尾部垃圾") { - // 少于一个完整头部(44 字节)的尾部一律当填充。 - std::vector transfer(kPacketMsgHeaderBytes + 64 + 43); - WritePacketMessage(transfer, MakeFrame(64, 0x45), kPacketInlineDataOffset, - kPacketMsgHeaderBytes + 64, 64); - // 尾部填成非零垃圾。 - for (std::size_t i = transfer.size() - 43; i < transfer.size(); ++i) { - transfer[i] = std::byte{0xEE}; + const auto summary = DecodeAll(transfer); + CHECK(summary.final_outcome == ReadOutcome::kEndOfTransfer); + REQUIRE(summary.frames.size() == 1); + CHECK(FrameMatches(summary.frames[0], 1514, 0x22)); } - const auto summary = DecodeAll(transfer); - CHECK(summary.final_outcome == ReadOutcome::kEndOfTransfer); - REQUIRE(summary.frames.size() == 1); - CHECK(summary.trailing_padding == 43); -} -TEST_CASE("解码空传输") { - const auto summary = DecodeAll({}); - CHECK(summary.final_outcome == ReadOutcome::kEndOfTransfer); - CHECK(summary.frames.empty()); -} + TEST_CASE("Decoding: the data does not follow the header immediately (DataOffset above 36)") { + // The device is free to leave a gap between the header and the data; the + // decoder must follow DataOffset. + constexpr std::uint32_t kGap = 16; + constexpr std::uint32_t kFrameLength = 100; + const std::uint32_t message_length = kPacketMsgHeaderBytes + kGap + kFrameLength; + std::vector transfer(message_length); + const auto frame = MakeFrame(kFrameLength, 0x33); + WritePacketMessage(transfer, frame, kPacketInlineDataOffset + kGap, message_length, + kFrameLength); -TEST_CASE("解码畸形输入:全部被识别且不读越界") { - SUBCASE("MessageType 不是 PACKET_MSG") { - std::vector transfer(kPacketMsgHeaderBytes + 64); - WritePacketMessage(transfer, MakeFrame(64, 1), kPacketInlineDataOffset, - kPacketMsgHeaderBytes + 64, 64); - StoreLe32(transfer.data() + kMessageTypeOffset, ToRaw(MessageType::kKeepAlive)); const auto summary = DecodeAll(transfer); - CHECK(summary.final_outcome == ReadOutcome::kMalformed); - CHECK(summary.reason == MalformedReason::kNotPacketMessage); + REQUIRE(summary.frames.size() == 1); + CHECK(FrameMatches(summary.frames[0], kFrameLength, 0x33)); } - SUBCASE("MessageLength 小于 44") { - std::vector transfer(kPacketMsgHeaderBytes + 64); - WritePacketMessage(transfer, MakeFrame(64, 1), kPacketInlineDataOffset, 40, 64); - const auto summary = DecodeAll(transfer); - CHECK(summary.final_outcome == ReadOutcome::kMalformed); - CHECK(summary.reason == MalformedReason::kMessageTooShort); - } + TEST_CASE("Decoding: tolerates the 1-byte ZLP-avoidance padding at the tail") { + // This tolerance is mandatory -- otherwise every full transfer reports a + // spurious malformed frame. + std::vector transfer(kPacketMsgHeaderBytes + 64 + 1); + WritePacketMessage(transfer, MakeFrame(64, 0x44), kPacketInlineDataOffset, + kPacketMsgHeaderBytes + 64, 64); - SUBCASE("MessageLength 超出剩余缓冲") { - std::vector transfer(kPacketMsgHeaderBytes + 64); - WritePacketMessage(transfer, MakeFrame(64, 1), kPacketInlineDataOffset, 0xFFFF'FFFFU, 64); const auto summary = DecodeAll(transfer); - CHECK(summary.final_outcome == ReadOutcome::kMalformed); - CHECK(summary.reason == MalformedReason::kMessageOverruns); + CHECK(summary.final_outcome == ReadOutcome::kEndOfTransfer); + CHECK(summary.reason == MalformedReason::kNone); + REQUIRE(summary.frames.size() == 1); + CHECK(summary.trailing_padding == 1); } - SUBCASE("DataOffset + DataLength 越界") { - std::vector transfer(kPacketMsgHeaderBytes + 64); - WritePacketMessage(transfer, {}, kPacketInlineDataOffset, kPacketMsgHeaderBytes + 64, 1000); + TEST_CASE("Decoding: tolerates up to 43 bytes of trailing garbage") { + // A tail shorter than one full header (44 bytes) is always treated as + // padding. + std::vector transfer(kPacketMsgHeaderBytes + 64 + 43); + WritePacketMessage(transfer, MakeFrame(64, 0x45), kPacketInlineDataOffset, + kPacketMsgHeaderBytes + 64, 64); + // Fill the tail with non-zero garbage. + for (std::size_t i = transfer.size() - 43; i < transfer.size(); ++i) { + transfer[i] = std::byte{0xEE}; + } const auto summary = DecodeAll(transfer); - CHECK(summary.final_outcome == ReadOutcome::kMalformed); - CHECK(summary.reason == MalformedReason::kDataOutOfBounds); + CHECK(summary.final_outcome == ReadOutcome::kEndOfTransfer); + REQUIRE(summary.frames.size() == 1); + CHECK(summary.trailing_padding == 43); } - SUBCASE("DataOffset 巨大值不得整数溢出") { - std::vector transfer(kPacketMsgHeaderBytes + 64); - WritePacketMessage(transfer, {}, 0xFFFF'FFF8U, kPacketMsgHeaderBytes + 64, 64); - const auto summary = DecodeAll(transfer); - CHECK(summary.final_outcome == ReadOutcome::kMalformed); - CHECK(summary.reason == MalformedReason::kDataOutOfBounds); + TEST_CASE("Decoding an empty transfer") { + const auto summary = DecodeAll({}); + CHECK(summary.final_outcome == ReadOutcome::kEndOfTransfer); + CHECK(summary.frames.empty()); } - SUBCASE("DataLength 小于以太头长度") { - std::vector transfer(kPacketMsgHeaderBytes + 64); - WritePacketMessage(transfer, MakeFrame(13, 1), kPacketInlineDataOffset, - kPacketMsgHeaderBytes + 64, 13); - const auto summary = DecodeAll(transfer); - CHECK(summary.final_outcome == ReadOutcome::kMalformed); - CHECK(summary.reason == MalformedReason::kFrameTooShort); + TEST_CASE("Decoding malformed input: all of it is caught without reading out of bounds") { + SUBCASE("MessageType is not PACKET_MSG") { + std::vector transfer(kPacketMsgHeaderBytes + 64); + WritePacketMessage(transfer, MakeFrame(64, 1), kPacketInlineDataOffset, + kPacketMsgHeaderBytes + 64, 64); + StoreLe32(transfer.data() + kMessageTypeOffset, ToRaw(MessageType::kKeepAlive)); + const auto summary = DecodeAll(transfer); + CHECK(summary.final_outcome == ReadOutcome::kMalformed); + CHECK(summary.reason == MalformedReason::kNotPacketMessage); + } + + SUBCASE("MessageLength is less than 44") { + std::vector transfer(kPacketMsgHeaderBytes + 64); + WritePacketMessage(transfer, MakeFrame(64, 1), kPacketInlineDataOffset, 40, 64); + const auto summary = DecodeAll(transfer); + CHECK(summary.final_outcome == ReadOutcome::kMalformed); + CHECK(summary.reason == MalformedReason::kMessageTooShort); + } + + SUBCASE("MessageLength overruns the remaining buffer") { + std::vector transfer(kPacketMsgHeaderBytes + 64); + WritePacketMessage(transfer, MakeFrame(64, 1), kPacketInlineDataOffset, 0xFFFF'FFFFU, 64); + const auto summary = DecodeAll(transfer); + CHECK(summary.final_outcome == ReadOutcome::kMalformed); + CHECK(summary.reason == MalformedReason::kMessageOverruns); + } + + SUBCASE("DataOffset + DataLength goes out of bounds") { + std::vector transfer(kPacketMsgHeaderBytes + 64); + WritePacketMessage(transfer, {}, kPacketInlineDataOffset, kPacketMsgHeaderBytes + 64, 1000); + const auto summary = DecodeAll(transfer); + CHECK(summary.final_outcome == ReadOutcome::kMalformed); + CHECK(summary.reason == MalformedReason::kDataOutOfBounds); + } + + SUBCASE("a huge DataOffset must not overflow the integer") { + std::vector transfer(kPacketMsgHeaderBytes + 64); + WritePacketMessage(transfer, {}, 0xFFFF'FFF8U, kPacketMsgHeaderBytes + 64, 64); + const auto summary = DecodeAll(transfer); + CHECK(summary.final_outcome == ReadOutcome::kMalformed); + CHECK(summary.reason == MalformedReason::kDataOutOfBounds); + } + + SUBCASE("DataLength is less than the Ethernet header length") { + std::vector transfer(kPacketMsgHeaderBytes + 64); + WritePacketMessage(transfer, MakeFrame(13, 1), kPacketInlineDataOffset, + kPacketMsgHeaderBytes + 64, 13); + const auto summary = DecodeAll(transfer); + CHECK(summary.final_outcome == ReadOutcome::kMalformed); + CHECK(summary.reason == MalformedReason::kFrameTooShort); + } + + SUBCASE("DataLength exceeds the local single-frame limit") { + const std::uint32_t message_length = kPacketMsgHeaderBytes + 3000; + std::vector transfer(message_length); + WritePacketMessage(transfer, {}, kPacketInlineDataOffset, message_length, 3000); + const auto summary = DecodeAll(transfer, 2048); + CHECK(summary.final_outcome == ReadOutcome::kMalformed); + CHECK(summary.reason == MalformedReason::kFrameTooLong); + } } - SUBCASE("DataLength 超过本机单帧上限") { - const std::uint32_t message_length = kPacketMsgHeaderBytes + 3000; - std::vector transfer(message_length); - WritePacketMessage(transfer, {}, kPacketInlineDataOffset, message_length, 3000); - const auto summary = DecodeAll(transfer, 2048); + TEST_CASE("Decoding: the frames already decoded are kept when a later one is malformed") { + // This is what a buggy real device must not be allowed to do: the first 2 + // frames must not be lost because the 3rd message is broken. + std::array transfer{}; + PacketMessageWriter writer( + transfer, {.max_transfer_bytes = 8192, .max_messages = 3, .alignment_bytes = 1}, 512); + REQUIRE(writer.TryAppend(MakeFrame(64, 0x01))); + REQUIRE(writer.TryAppend(MakeFrame(64, 0x02))); + REQUIRE(writer.TryAppend(MakeFrame(64, 0x03))); + const std::uint32_t total = writer.Finish(); + + // Corrupt the type field of the third message. + // With 1-byte alignment the messages sit next to each other, so the third + // message's offset is the sum of the first two. + const std::size_t third_offset = std::size_t{2} * (kPacketMsgHeaderBytes + 64); + StoreLe32(transfer.data() + third_offset + kMessageTypeOffset, 0xDEAD'BEEFU); + + const auto summary = DecodeAll(std::span{transfer}.first(total)); CHECK(summary.final_outcome == ReadOutcome::kMalformed); - CHECK(summary.reason == MalformedReason::kFrameTooLong); + CHECK(summary.reason == MalformedReason::kNotPacketMessage); + REQUIRE(summary.frames.size() == 2); + CHECK(FrameMatches(summary.frames[0], 64, 0x01)); + CHECK(FrameMatches(summary.frames[1], 64, 0x02)); } -} - -TEST_CASE("解码:前几帧有效、中途畸形时保留已解出的帧") { - // 真实设备出 bug 时的行为要求:不能因为第 3 个消息坏了就丢掉前 2 帧。 - std::array transfer{}; - PacketMessageWriter writer(transfer, - {.max_transfer_bytes = 8192, .max_messages = 3, .alignment_bytes = 1}, - 512); - REQUIRE(writer.TryAppend(MakeFrame(64, 0x01))); - REQUIRE(writer.TryAppend(MakeFrame(64, 0x02))); - REQUIRE(writer.TryAppend(MakeFrame(64, 0x03))); - const std::uint32_t total = writer.Finish(); - - // 破坏第三个消息的类型字段。 - // 对齐 1 字节时消息紧邻排布,第三个消息的偏移就是前两个之和。 - const std::size_t third_offset = std::size_t{2} * (kPacketMsgHeaderBytes + 64); - StoreLe32(transfer.data() + third_offset + kMessageTypeOffset, 0xDEAD'BEEFU); - - const auto summary = DecodeAll(std::span{transfer}.first(total)); - CHECK(summary.final_outcome == ReadOutcome::kMalformed); - CHECK(summary.reason == MalformedReason::kNotPacketMessage); - REQUIRE(summary.frames.size() == 2); - CHECK(FrameMatches(summary.frames[0], 64, 0x01)); - CHECK(FrameMatches(summary.frames[1], 64, 0x02)); -} -TEST_CASE("解码:帧视图指向传输缓冲内部,无拷贝") { - std::vector transfer(kPacketMsgHeaderBytes + 100); - WritePacketMessage(transfer, MakeFrame(100, 0x77), kPacketInlineDataOffset, - kPacketMsgHeaderBytes + 100, 100); - - PacketMessageReader reader(transfer, kMaxFrame); - std::span frame; - REQUIRE(reader.Next(frame) == ReadOutcome::kFrame); - // 零拷贝的证明:帧指针落在传输缓冲内部的绝对偏移 44 处。 - CHECK(frame.data() == transfer.data() + 44); - CHECK(reader.FramesDecoded() == 1); - CHECK(reader.BytesConsumed() == kPacketMsgHeaderBytes + 100); -} + TEST_CASE("Decoding: the frame view points inside the transfer buffer, no copy") { + std::vector transfer(kPacketMsgHeaderBytes + 100); + WritePacketMessage(transfer, MakeFrame(100, 0x77), kPacketInlineDataOffset, + kPacketMsgHeaderBytes + 100, 100); + + PacketMessageReader reader(transfer, kMaxFrame); + std::span frame; + REQUIRE(reader.Next(frame) == ReadOutcome::kFrame); + // Proof of the zero copy: the frame pointer lands at absolute offset 44 + // inside the transfer buffer. + CHECK(frame.data() == transfer.data() + 44); + CHECK(reader.FramesDecoded() == 1); + CHECK(reader.BytesConsumed() == kPacketMsgHeaderBytes + 100); + } TEST_CASE("MalformedReason names are complete") { // The names are user-facing text and follow the language, so pin it first. @@ -439,10 +449,10 @@ TEST_CASE("MalformedReason names are complete") { } // --------------------------------------------------------------------------- -// 往返性质测试 +// Round-trip property tests // --------------------------------------------------------------------------- -TEST_CASE("往返:各种对齐因子与帧长组合都能无损还原") { +TEST_CASE("Round trip: every alignment factor and frame length is reproduced losslessly") { std::array transfer{}; for (std::uint32_t factor = 0; factor <= kMaxPacketAlignmentFactor; ++factor) { const std::uint32_t alignment = 1U << factor; diff --git a/tests/test_rndis_state_machine.cc b/tests/test_rndis_state_machine.cc index 1ed5d31..2e773b7 100644 --- a/tests/test_rndis_state_machine.cc +++ b/tests/test_rndis_state_machine.cc @@ -1,8 +1,10 @@ -// RNDIS 状态机的单元测试。 +// Unit tests for the RNDIS state machine. // -// 状态机是整个协议实现里逻辑最复杂的部分,而它在真机上的很多路径极难复现 -// (设备插队推送、保活失败、复位后要求重放)。这里用 MockControlChannel 扮演 -// 设备侧,把这些路径全部覆盖到。 +// The state machine is the most intricate part of the protocol implementation, +// and many of its paths are nearly impossible to reproduce on a real device +// (the device pushing an out-of-band message, a keep-alive failure, a replay +// demand after a reset). MockControlChannel plays the device side here, so +// all of those paths get covered. #include #include #include @@ -16,9 +18,10 @@ #include "mock_control_channel.h" #include "tetherkit/rndis/state_machine.h" -// 与 test_net_link.cc 里同名助手同理:直接在 REQUIRE_MESSAGE 里写 -// `r ? "" : r.error().ToString()` 编译不过 —— doctest 的 MessageBuilder 会把 -// 整个三元表达式吞进流里再试图转 bool。 +// Same reasoning as the identically named helper in test_net_link.cc: writing +// `r ? "" : r.error().ToString()` directly inside REQUIRE_MESSAGE does not +// compile -- doctest's MessageBuilder swallows the whole ternary expression +// into the stream and then tries to convert it to bool. namespace { template std::string Why(const T& result) { @@ -47,283 +50,296 @@ namespace { constexpr MacAddress kDeviceMac{0x02, 0x1A, 0x11, 0x22, 0x33, 0x44}; -/// 测试用配置:把轮询间隔压到 0,让测试跑得快。 +/// Test configuration: squeezes the poll interval down to 0 so the tests run fast. [[nodiscard]] StateMachineConfig FastConfig() { StateMachineConfig config; config.response_poll_interval_millis = 0; config.response_poll_attempts = 4; - config.keepalive_interval_millis = 0; // 让保活立即到期,便于测试 + config.keepalive_interval_millis = 0; // make the keep-alive expire at once, for testability return config; } } // namespace TEST_SUITE("rndis.state_machine") { - -TEST_CASE("状态名齐全") { - CHECK_FALSE(StateName(State::kUninitialized).empty()); - CHECK_FALSE(StateName(State::kInitializing).empty()); - CHECK_FALSE(StateName(State::kInitialized).empty()); - CHECK_FALSE(StateName(State::kDataInitialized).empty()); - CHECK_FALSE(StateName(State::kHalting).empty()); -} - -TEST_CASE("完整启动序列:走到 kDataInitialized 并通报协商结果") { - MockControlChannel channel; - channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - - CHECK(machine.CurrentState() == State::kUninitialized); - const auto status = machine.Start(); - REQUIRE_MESSAGE(status.has_value(), Why(status)); - - CHECK(machine.CurrentState() == State::kDataInitialized); - - // 状态迁移路径必须是 未初始化 → 初始化中 → 已初始化 → 数据已就绪。 - REQUIRE(observer.transitions.size() == 3); - CHECK(observer.transitions[0].to == State::kInitializing); - CHECK(observer.transitions[1].to == State::kInitialized); - CHECK(observer.transitions[2].to == State::kDataInitialized); - - CHECK(observer.negotiated_count == 1); - CHECK(observer.parameters_snapshot.mtu == 1500); - CHECK(observer.parameters_snapshot.device_max_transfer_size == 2048); - CHECK(observer.parameters_snapshot.max_packets_per_message == 1); - CHECK(observer.parameters_snapshot.tx_alignment_bytes == 1); - - // 拿到了设备 MAC —— 这是主机侧 feth 要用的地址。 - CHECK(machine.Info().has_permanent_address); - CHECK(machine.Info().permanent_address == kDeviceMac); - CHECK(machine.Info().LinkSpeedMbps() == doctest::Approx(480.0)); - - // 链路通报了一次「已连接」。 - REQUIRE(observer.link_events.size() == 1); - CHECK(observer.link_events[0]); -} - -TEST_CASE("启动序列的消息顺序:INITIALIZE 在最前,SET 包过滤在最后") { - MockControlChannel channel; - channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - REQUIRE(machine.Start().has_value()); - - REQUIRE_FALSE(channel.SentMessages().empty()); - // 第一条必须是 INITIALIZE_MSG。 - CHECK(channel.SentMessageType(0) == ToRaw(MessageType::kInitialize)); - // 最后一条必须是 SET(包过滤)—— 它才让设备进入 data-initialized。 - const std::size_t last = channel.SentMessages().size() - 1; - CHECK(channel.SentMessageType(last) == ToRaw(MessageType::kSet)); - - // 只发了一条 INITIALIZE,没有重复。 - CHECK(channel.CountSent(MessageType::kInitialize) == 1); - - // 设置的包过滤值必须是我们要求的那个(DIRECTED|BROADCAST|ALL_MULTICAST|PROMISCUOUS)。 - std::uint32_t filter = 0; - REQUIRE(channel.FindSetUint32(Oid::kGenCurrentPacketFilter, filter)); - CHECK(filter == kDefaultPacketFilter); - CHECK(filter == 0x0000'002DU); -} - -TEST_CASE("变长 OID 的 InformationBufferLength 必须为 0,定长必须非 0") { - MockControlChannel channel; - std::vector> query_oid_and_length; - channel.SetRequestHandler( - [&](std::span request, MockControlChannel& mock) { - const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); - if (type == ToRaw(MessageType::kQuery)) { - query_oid_and_length.emplace_back( - OidOf(request), LoadLe32(request.data() + kQuerySetInfoBufferLengthOffset)); - } - MakeWellBehavedDevice(kDeviceMac)(request, mock); - }); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - REQUIRE(machine.Start().has_value()); - - bool saw_fixed = false; - bool saw_variable = false; - for (const auto& [oid, length] : query_oid_and_length) { - if (IsVariableLengthOid(static_cast(oid))) { - // 变长 OID 传非零长度会被 ActiveSync 实现拒绝。 - CHECK_MESSAGE(length == 0, "变长 OID " << OidName(oid) << " 的长度应为 0"); - saw_variable = true; - } else { - CHECK_MESSAGE(length > 0, "定长 OID " << OidName(oid) << " 的长度应大于 0"); - saw_fixed = true; - } + TEST_CASE("State names are all present") { + CHECK_FALSE(StateName(State::kUninitialized).empty()); + CHECK_FALSE(StateName(State::kInitializing).empty()); + CHECK_FALSE(StateName(State::kInitialized).empty()); + CHECK_FALSE(StateName(State::kDataInitialized).empty()); + CHECK_FALSE(StateName(State::kHalting).empty()); } - CHECK(saw_fixed); - CHECK(saw_variable); // 启动序列里查了 OID_GEN_VENDOR_DESCRIPTION -} - -TEST_CASE("可选 OID 返回 NOT_SUPPORTED 不影响启动") { - // OID_GEN_PHYSICAL_MEDIUM 是可选的,设备回不支持是完全正常的。 - MockControlChannel channel; - channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - REQUIRE(machine.Start().has_value()); - CHECK(machine.CurrentState() == State::kDataInitialized); - CHECK(machine.Info().physical_medium == PhysicalMedium::kUnspecified); - CHECK(observer.fatal_errors.empty()); -} -TEST_CASE("拿不到永久 MAC 是致命错误,且会发 HALT") { - MockControlChannel channel; - channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { - const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); - const std::uint32_t request_id = RequestIdOf(request); - if (type == ToRaw(MessageType::kInitialize)) { - mock.EnqueueResponse(MakeInitializeComplete(request_id)); - return; - } - if (type == ToRaw(MessageType::kQuery)) { - // 所有 QUERY 都回不支持 —— 包括永久 MAC。 - mock.EnqueueResponse( - MakeQueryComplete(request_id, {}, ToRaw(StatusCode::kNotSupported))); - } - }); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - - const auto status = machine.Start(); - REQUIRE_FALSE(status.has_value()); - // 失败后必须回到未初始化,而不是卡在中间状态。 - CHECK(machine.CurrentState() == State::kUninitialized); - // 且必须发过 HALT 来清理设备侧状态。 - CHECK(channel.CountSent(MessageType::kHalt) == 1); -} - -TEST_CASE("设备拒绝初始化时不发 QUERY,直接失败") { - MockControlChannel channel; - channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { - const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); - if (type == ToRaw(MessageType::kInitialize)) { - mock.EnqueueResponse(MakeInitializeComplete(RequestIdOf(request), 2048, 1, 0, - ToRaw(StatusCode::kFailure))); - } - }); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - - REQUIRE_FALSE(machine.Start().has_value()); - CHECK(machine.CurrentState() == State::kUninitialized); - CHECK(channel.CountSent(MessageType::kQuery) == 0); -} + TEST_CASE("Full startup sequence: reaches kDataInitialized and reports the negotiated result") { + MockControlChannel channel; + channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + + CHECK(machine.CurrentState() == State::kUninitialized); + const auto status = machine.Start(); + REQUIRE_MESSAGE(status.has_value(), Why(status)); + + CHECK(machine.CurrentState() == State::kDataInitialized); + + // The transition path must be uninitialized -> initializing -> initialized -> data ready. + REQUIRE(observer.transitions.size() == 3); + CHECK(observer.transitions[0].to == State::kInitializing); + CHECK(observer.transitions[1].to == State::kInitialized); + CHECK(observer.transitions[2].to == State::kDataInitialized); + + CHECK(observer.negotiated_count == 1); + CHECK(observer.parameters_snapshot.mtu == 1500); + CHECK(observer.parameters_snapshot.device_max_transfer_size == 2048); + CHECK(observer.parameters_snapshot.max_packets_per_message == 1); + CHECK(observer.parameters_snapshot.tx_alignment_bytes == 1); + + // Got the device MAC -- this is the address the host-side feth will use. + CHECK(machine.Info().has_permanent_address); + CHECK(machine.Info().permanent_address == kDeviceMac); + CHECK(machine.Info().LinkSpeedMbps() == doctest::Approx(480.0)); + + // The link reported "connected" once. + REQUIRE(observer.link_events.size() == 1); + CHECK(observer.link_events[0]); + } -TEST_CASE("非以太网介质被拒绝") { - MockControlChannel channel; - channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { - if (LoadLe32(request.data() + kMessageTypeOffset) == ToRaw(MessageType::kInitialize)) { - auto message = MakeInitializeComplete(RequestIdOf(request)); - StoreLe32(message.data() + kInitializeCmpltMediumOffset, - static_cast(Medium::kWirelessLan)); - mock.EnqueueResponse(std::move(message)); - } - }); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - REQUIRE_FALSE(machine.Start().has_value()); -} + TEST_CASE("Startup message order: INITIALIZE first, the SET packet filter last") { + MockControlChannel channel; + channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + REQUIRE(machine.Start().has_value()); + + REQUIRE_FALSE(channel.SentMessages().empty()); + // The first message must be INITIALIZE_MSG. + CHECK(channel.SentMessageType(0) == ToRaw(MessageType::kInitialize)); + // The last message must be the SET (packet filter) -- that is what puts the + // device into data-initialized. + const std::size_t last = channel.SentMessages().size() - 1; + CHECK(channel.SentMessageType(last) == ToRaw(MessageType::kSet)); + + // Exactly one INITIALIZE was sent, no duplicate. + CHECK(channel.CountSent(MessageType::kInitialize) == 1); + + // The packet filter value set must be the one we asked for + // (DIRECTED|BROADCAST|ALL_MULTICAST|PROMISCUOUS). + std::uint32_t filter = 0; + REQUIRE(channel.FindSetUint32(Oid::kGenCurrentPacketFilter, filter)); + CHECK(filter == kDefaultPacketFilter); + CHECK(filter == 0x0000'002DU); + } -TEST_CASE("设备在等响应期间插入 INDICATE_STATUS,不影响请求配对") { - // 这是真机上很常见但极难主动复现的路径。 - MockControlChannel channel; - channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { - const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); - if (type == ToRaw(MessageType::kInitialize)) { - // 先塞一条媒体断开通报,再塞真正的响应。 - mock.EnqueueResponse(MakeIndicateStatus(StatusCode::kMediaDisconnect)); - mock.EnqueueResponse(MakeInitializeComplete(RequestIdOf(request))); - return; + TEST_CASE("Variable-length OIDs must use InformationBufferLength 0, fixed-length ones non-zero") { + MockControlChannel channel; + std::vector> query_oid_and_length; + channel.SetRequestHandler([&](std::span request, MockControlChannel& mock) { + const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); + if (type == ToRaw(MessageType::kQuery)) { + query_oid_and_length.emplace_back( + OidOf(request), LoadLe32(request.data() + kQuerySetInfoBufferLengthOffset)); + } + MakeWellBehavedDevice(kDeviceMac)(request, mock); + }); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + REQUIRE(machine.Start().has_value()); + + bool saw_fixed = false; + bool saw_variable = false; + for (const auto& [oid, length] : query_oid_and_length) { + if (IsVariableLengthOid(static_cast(oid))) { + // A non-zero length on a variable-length OID is rejected by the + // ActiveSync implementation. + CHECK_MESSAGE(length == 0, + "variable-length OID " << OidName(oid) << " should have length 0"); + saw_variable = true; + } else { + CHECK_MESSAGE(length > 0, + "fixed-length OID " << OidName(oid) << " should have length greater than 0"); + saw_fixed = true; + } } - MakeWellBehavedDevice(kDeviceMac)(request, mock); - }); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - - const auto status = machine.Start(); - REQUIRE_MESSAGE(status.has_value(), Why(status)); - CHECK(machine.CurrentState() == State::kDataInitialized); - - // 插队消息必须被消费掉,不能堆在队列里挤占后续响应。 - CHECK(channel.PendingResponseCount() == 0); - - // 关于链路事件:链路状态是**边沿触发**的。启动时内部认定的初始状态就是 - // 「未连接」,所以一条 MEDIA_DISCONNECT 与当前认知一致 → 正确地不产生事件 - // (去重)。随后 QUERY OID_GEN_MEDIA_CONNECT_STATUS 返回 0(已连接), - // 这才是更新的、权威的状态,于是 Start() 结束时通报一次「已连接」。 - REQUIRE(observer.link_events.size() == 1); - CHECK(observer.link_events[0]); -} - -TEST_CASE("链路已 up 之后收到 MEDIA_DISCONNECT 才产生断开事件") { - // 这是 MEDIA_DISCONNECT 真正该触发事件的场景:存在 up → down 的边沿。 - MockControlChannel channel; - channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); - RecordingObserver observer; - auto config = FastConfig(); - config.keepalive_interval_millis = 60'000; // 别让保活干扰 - StateMachine machine(channel, observer, config); - REQUIRE(machine.Start().has_value()); + CHECK(saw_fixed); + CHECK(saw_variable); // the startup sequence queried OID_GEN_VENDOR_DESCRIPTION + } - // 启动后链路是 up 的。 - REQUIRE(observer.link_events.size() == 1); - REQUIRE(observer.link_events[0]); + TEST_CASE("An optional OID answering NOT_SUPPORTED does not block startup") { + // OID_GEN_PHYSICAL_MEDIUM is optional; a device that does not support it + // is perfectly normal. + MockControlChannel channel; + channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + REQUIRE(machine.Start().has_value()); + CHECK(machine.CurrentState() == State::kDataInitialized); + CHECK(machine.Info().physical_medium == PhysicalMedium::kUnspecified); + CHECK(observer.fatal_errors.empty()); + } - // 设备现在推一条 MEDIA_DISCONNECT,Poll 应把它取走并通报断开。 - channel.EnqueueResponse(MakeIndicateStatus(StatusCode::kMediaDisconnect)); - REQUIRE(machine.Poll().has_value()); + TEST_CASE("Not getting the permanent MAC is a fatal error, and a HALT is sent") { + MockControlChannel channel; + channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { + const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); + const std::uint32_t request_id = RequestIdOf(request); + if (type == ToRaw(MessageType::kInitialize)) { + mock.EnqueueResponse(MakeInitializeComplete(request_id)); + return; + } + if (type == ToRaw(MessageType::kQuery)) { + // Every QUERY is answered with "not supported" -- including the + // permanent MAC. + mock.EnqueueResponse(MakeQueryComplete(request_id, {}, ToRaw(StatusCode::kNotSupported))); + } + }); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + + const auto status = machine.Start(); + REQUIRE_FALSE(status.has_value()); + // After the failure it must go back to uninitialized, not get stuck in an + // intermediate state. + CHECK(machine.CurrentState() == State::kUninitialized); + // And a HALT must have been sent to clean up the device-side state. + CHECK(channel.CountSent(MessageType::kHalt) == 1); + } - REQUIRE(observer.link_events.size() == 2); - CHECK_FALSE(observer.link_events[1]); + TEST_CASE("When the device rejects initialization no QUERY is sent, it fails right away") { + MockControlChannel channel; + channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { + const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); + if (type == ToRaw(MessageType::kInitialize)) { + mock.EnqueueResponse( + MakeInitializeComplete(RequestIdOf(request), 2048, 1, 0, ToRaw(StatusCode::kFailure))); + } + }); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + + REQUIRE_FALSE(machine.Start().has_value()); + CHECK(machine.CurrentState() == State::kUninitialized); + CHECK(channel.CountSent(MessageType::kQuery) == 0); + } - // 再推一条相同的通报 —— 边沿触发,不该再产生事件。 - channel.EnqueueResponse(MakeIndicateStatus(StatusCode::kMediaDisconnect)); - REQUIRE(machine.Poll().has_value()); - CHECK(observer.link_events.size() == 2); + TEST_CASE("A non-Ethernet medium is rejected") { + MockControlChannel channel; + channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { + if (LoadLe32(request.data() + kMessageTypeOffset) == ToRaw(MessageType::kInitialize)) { + auto message = MakeInitializeComplete(RequestIdOf(request)); + StoreLe32(message.data() + kInitializeCmpltMediumOffset, + static_cast(Medium::kWirelessLan)); + mock.EnqueueResponse(std::move(message)); + } + }); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + REQUIRE_FALSE(machine.Start().has_value()); + } - // 恢复连接时应产生 down → up 的边沿。 - channel.EnqueueResponse(MakeIndicateStatus(StatusCode::kMediaConnect)); - REQUIRE(machine.Poll().has_value()); - REQUIRE(observer.link_events.size() == 3); - CHECK(observer.link_events[2]); -} + TEST_CASE("An INDICATE_STATUS inserted mid-request does not disturb request matching") { + // This is a very common path on real hardware but nearly impossible to + // reproduce on purpose. + MockControlChannel channel; + channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { + const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); + if (type == ToRaw(MessageType::kInitialize)) { + // Queue a media-disconnect notification first, then the real response. + mock.EnqueueResponse(MakeIndicateStatus(StatusCode::kMediaDisconnect)); + mock.EnqueueResponse(MakeInitializeComplete(RequestIdOf(request))); + return; + } + MakeWellBehavedDevice(kDeviceMac)(request, mock); + }); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + + const auto status = machine.Start(); + REQUIRE_MESSAGE(status.has_value(), Why(status)); + CHECK(machine.CurrentState() == State::kDataInitialized); + + // The out-of-band message must be consumed, not left to crowd the queue + // ahead of the later responses. + CHECK(channel.PendingResponseCount() == 0); + + // About link events: the link state is **edge triggered**. The initial + // state the implementation assumes at startup is "not connected", so one + // MEDIA_DISCONNECT agrees with what we believe -> correctly produces no + // event (deduplication). The subsequent QUERY of + // OID_GEN_MEDIA_CONNECT_STATUS returns 0 (connected), and that is the + // newer, authoritative state, so Start() reports "connected" once when it + // finishes. + REQUIRE(observer.link_events.size() == 1); + CHECK(observer.link_events[0]); + } -TEST_CASE("设备主动发 KEEPALIVE_MSG 时主机必须回 KEEPALIVE_CMPLT") { - // 不回的话设备可能判定主机已死而断开连接。 - MockControlChannel channel; - constexpr std::uint32_t kDeviceKeepAliveId = 0xABCD'1234; - channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { - const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); - if (type == ToRaw(MessageType::kInitialize)) { - // 在响应之前插入一条设备发起的保活。 - mock.EnqueueResponse(MakeDeviceKeepAlive(kDeviceKeepAliveId)); - mock.EnqueueResponse(MakeInitializeComplete(RequestIdOf(request))); - return; - } - MakeWellBehavedDevice(kDeviceMac)(request, mock); - }); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - REQUIRE(machine.Start().has_value()); + TEST_CASE("A MEDIA_DISCONNECT after the link is up is what produces the disconnect event") { + // This is the case where MEDIA_DISCONNECT really should raise an event: + // there is an up -> down edge. + MockControlChannel channel; + channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); + RecordingObserver observer; + auto config = FastConfig(); + config.keepalive_interval_millis = 60'000; // keep the keep-alive out of the way + StateMachine machine(channel, observer, config); + REQUIRE(machine.Start().has_value()); + + // After startup the link is up. + REQUIRE(observer.link_events.size() == 1); + REQUIRE(observer.link_events[0]); + + // The device now pushes a MEDIA_DISCONNECT; Poll must pick it up and report + // the disconnect. + channel.EnqueueResponse(MakeIndicateStatus(StatusCode::kMediaDisconnect)); + REQUIRE(machine.Poll().has_value()); + + REQUIRE(observer.link_events.size() == 2); + CHECK_FALSE(observer.link_events[1]); + + // Push the same notification again -- edge triggered, no new event. + channel.EnqueueResponse(MakeIndicateStatus(StatusCode::kMediaDisconnect)); + REQUIRE(machine.Poll().has_value()); + CHECK(observer.link_events.size() == 2); + + // Recovering the connection must produce a down -> up edge. + channel.EnqueueResponse(MakeIndicateStatus(StatusCode::kMediaConnect)); + REQUIRE(machine.Poll().has_value()); + REQUIRE(observer.link_events.size() == 3); + CHECK(observer.link_events[2]); + } - // 主机必须发过一条 KEEPALIVE_CMPLT,且 RequestId 是设备给的那个。 - bool found = false; - for (const std::vector& message : channel.SentMessages()) { - if (message.size() >= kKeepAliveCmpltBytes && - LoadLe32(message.data() + kMessageTypeOffset) == - ToRaw(MessageType::kKeepAliveComplete)) { - CHECK(LoadLe32(message.data() + kKeepAliveCmpltRequestIdOffset) == kDeviceKeepAliveId); - CHECK(LoadLe32(message.data() + kKeepAliveCmpltStatusOffset) == - ToRaw(StatusCode::kSuccess)); - found = true; + TEST_CASE("When the device sends a KEEPALIVE_MSG the host must answer with a KEEPALIVE_CMPLT") { + // Not answering can make the device conclude that the host is dead and drop + // the connection. + MockControlChannel channel; + constexpr std::uint32_t kDeviceKeepAliveId = 0xABCD'1234; + channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { + const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); + if (type == ToRaw(MessageType::kInitialize)) { + // Insert a device-initiated keep-alive before the response. + mock.EnqueueResponse(MakeDeviceKeepAlive(kDeviceKeepAliveId)); + mock.EnqueueResponse(MakeInitializeComplete(RequestIdOf(request))); + return; + } + MakeWellBehavedDevice(kDeviceMac)(request, mock); + }); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + REQUIRE(machine.Start().has_value()); + + // The host must have sent a KEEPALIVE_CMPLT whose RequestId is the one the + // device gave. + bool found = false; + for (const std::vector& message : channel.SentMessages()) { + if (message.size() >= kKeepAliveCmpltBytes && + LoadLe32(message.data() + kMessageTypeOffset) == ToRaw(MessageType::kKeepAliveComplete)) { + CHECK(LoadLe32(message.data() + kKeepAliveCmpltRequestIdOffset) == kDeviceKeepAliveId); + CHECK(LoadLe32(message.data() + kKeepAliveCmpltStatusOffset) == + ToRaw(StatusCode::kSuccess)); + found = true; + } } + CHECK_MESSAGE(found, "the host did not reply to the device-initiated KEEPALIVE"); } - CHECK_MESSAGE(found, "主机没有回复设备发起的 KEEPALIVE"); -} TEST_CASE("messages aimed at connection-oriented devices are explicitly rejected") { // The error message wording is asserted below; pin the language. @@ -331,7 +347,7 @@ TEST_CASE("messages aimed at connection-oriented devices are explicitly rejected MockControlChannel channel; channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { if (LoadLe32(request.data() + kMessageTypeOffset) == ToRaw(MessageType::kInitialize)) { - // 塞一条 CONDIS 消息。 + // Queue a CONDIS message. std::vector condis(kMessageHeaderBytes); StoreLe32(condis.data() + kMessageTypeOffset, 0x0000'8001U); // MP_CREATE_VC StoreLe32(condis.data() + kMessageLengthOffset, kMessageHeaderBytes); @@ -354,7 +370,7 @@ TEST_CASE("keepalive success reports no error and sends none while the device is channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); RecordingObserver observer; auto config = FastConfig(); - // 保活周期设很大:Poll 不该发保活。 + // Keep-alive period set very large: Poll must not send a keep-alive. config.keepalive_interval_millis = 60'000; StateMachine machine(channel, observer, config); REQUIRE(machine.Start().has_value()); @@ -369,7 +385,7 @@ TEST_CASE("keepalive expiry sends KEEPALIVE and clears the failure count after s channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); RecordingObserver observer; auto config = FastConfig(); - config.keepalive_interval_millis = 0; // 立即到期 + config.keepalive_interval_millis = 0; // expires immediately StateMachine machine(channel, observer, config); REQUIRE(machine.Start().has_value()); @@ -383,11 +399,12 @@ TEST_CASE("reaching the keepalive failure threshold reports a fatal error") { // The fatal error wording is asserted below; pin the language. const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kEnglish}; MockControlChannel channel; - // 设备只回 INITIALIZE / QUERY / SET,对 KEEPALIVE 一律不回 → 每次都超时。 + // The device answers only INITIALIZE / QUERY / SET and never KEEPALIVE -> + // every poll times out. channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); if (type == ToRaw(MessageType::kKeepAlive)) { - return; // 故意不回 + return; // deliberately no reply } MakeWellBehavedDevice(kDeviceMac)(request, mock); }); @@ -398,20 +415,21 @@ TEST_CASE("reaching the keepalive failure threshold reports a fatal error") { StateMachine machine(channel, observer, config); REQUIRE(machine.Start().has_value()); - // 前两次失败不该致命。 + // The first two failures must not be fatal. CHECK(machine.Poll().has_value()); CHECK(observer.fatal_errors.empty()); CHECK(machine.Poll().has_value()); CHECK(observer.fatal_errors.empty()); - // 第三次达到阈值。 + // The third one reaches the threshold. const auto third = machine.Poll(); CHECK_FALSE(third.has_value()); REQUIRE(observer.fatal_errors.size() == 1); CHECK(observer.fatal_errors[0].find("keep-alive") != std::string::npos); } -TEST_CASE("软复位:AddressingReset 非零时必须重放包过滤") { - // 这是最容易漏的一条:不重放的话复位后数据就再也不流动了。 +TEST_CASE("Soft reset: when AddressingReset is non-zero the packet filter must be replayed") { + // This is the easiest one to miss: without the replay, data never flows + // again after the reset. MockControlChannel channel; channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); RecordingObserver observer; @@ -421,20 +439,20 @@ TEST_CASE("软复位:AddressingReset 非零时必须重放包过滤") { channel.ClearSentMessages(); REQUIRE(machine.Reset().has_value()); - // 通报了复位,且标明寻址信息已丢失。 + // The reset was reported, and it flags the addressing information as lost. REQUIRE(observer.reset_events.size() == 1); CHECK(observer.reset_events[0]); - // 必须发过 RESET_MSG。 + // A RESET_MSG must have been sent. CHECK(channel.CountSent(MessageType::kReset) == 1); - // 而且必须重放了包过滤。 + // And the packet filter must have been replayed. std::uint32_t filter = 0; REQUIRE_MESSAGE(channel.FindSetUint32(Oid::kGenCurrentPacketFilter, filter), - "复位后没有重放包过滤设置"); + "the packet filter setting was not replayed after the reset"); CHECK(filter == kDefaultPacketFilter); } -TEST_CASE("软复位:AddressingReset 为 0 时不必重放") { +TEST_CASE("Soft reset: when AddressingReset is 0 no replay is needed") { MockControlChannel channel; channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); @@ -457,7 +475,7 @@ TEST_CASE("软复位:AddressingReset 为 0 时不必重放") { CHECK_FALSE(channel.FindSetUint32(Oid::kGenCurrentPacketFilter, filter)); } -TEST_CASE("停机:先清零包过滤再发 HALT") { +TEST_CASE("Shutdown: clear the packet filter first, then send HALT") { MockControlChannel channel; channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); RecordingObserver observer; @@ -468,7 +486,8 @@ TEST_CASE("停机:先清零包过滤再发 HALT") { machine.Stop(); CHECK(machine.CurrentState() == State::kUninitialized); - // 必须先 SET filter = 0(让设备退回 initialized、停止发数据),再发 HALT。 + // Must SET filter = 0 first (so the device falls back to initialized and + // stops sending data), then send HALT. std::uint32_t filter = 0xFFFF'FFFFU; REQUIRE(channel.FindSetUint32(Oid::kGenCurrentPacketFilter, filter)); CHECK(filter == 0); @@ -478,7 +497,7 @@ TEST_CASE("停机:先清零包过滤再发 HALT") { CHECK(channel.SentMessageType(last) == ToRaw(MessageType::kHalt)); } -TEST_CASE("停机是幂等的") { +TEST_CASE("Shutdown is idempotent") { MockControlChannel channel; channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); RecordingObserver observer; @@ -491,7 +510,7 @@ TEST_CASE("停机是幂等的") { CHECK(channel.SentMessages().size() == after_first); } -TEST_CASE("Start 只能在未初始化状态下调用") { +TEST_CASE("Start may only be called in the uninitialized state") { MockControlChannel channel; channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); RecordingObserver observer; @@ -500,8 +519,9 @@ TEST_CASE("Start 只能在未初始化状态下调用") { CHECK_FALSE(machine.Start().has_value()); } -TEST_CASE("设备没有中断端点时退化为轮询,仍能完成启动") { - // Linux 的 host 驱动就完全忽略中断端点,纯靠轮询控制端点。 +TEST_CASE("A device without an interrupt endpoint degrades to polling, startup still completes") { + // The Linux host driver ignores the interrupt endpoint entirely and relies + // on polling the control endpoint. MockControlChannel channel; channel.SetHasInterruptEndpoint(false); channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); @@ -513,10 +533,10 @@ TEST_CASE("设备没有中断端点时退化为轮询,仍能完成启动") { CHECK(machine.CurrentState() == State::kDataInitialized); } -TEST_CASE("控制通道发送失败时启动失败且不卡在中间状态") { +TEST_CASE("A control channel send failure fails startup without getting stuck") { MockControlChannel channel; channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); - channel.FailSendOnCall(1); // 第一条 INITIALIZE 就发不出去 + channel.FailSendOnCall(1); // the very first INITIALIZE cannot be sent RecordingObserver observer; StateMachine machine(channel, observer, FastConfig()); @@ -524,7 +544,7 @@ TEST_CASE("控制通道发送失败时启动失败且不卡在中间状态") { CHECK(machine.CurrentState() == State::kUninitialized); } -TEST_CASE("设备 quirk:高通聚合补丁(MaxPacketsPerMessage=3、对齐因子 2)") { +TEST_CASE("Device quirk: Qualcomm aggregation patch (MaxPacketsPerMessage=3, alignment factor 2)") { MockControlChannel channel; channel.SetRequestHandler( MakeWellBehavedDevice(kDeviceMac, /*max_transfer_size=*/3 * 1600, @@ -537,7 +557,7 @@ TEST_CASE("设备 quirk:高通聚合补丁(MaxPacketsPerMessage=3、对齐 CHECK(observer.parameters_snapshot.tx_alignment_bytes == 4); // 1 << 2 } -TEST_CASE("设备 quirk:对齐因子越界被钳到 7") { +TEST_CASE("Device quirk: an out-of-range alignment factor is clamped to 7") { MockControlChannel channel; channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac, 2048, 1, /*alignment_factor=*/31)); RecordingObserver observer; @@ -546,7 +566,7 @@ TEST_CASE("设备 quirk:对齐因子越界被钳到 7") { CHECK(observer.parameters_snapshot.tx_alignment_bytes == 128); } -TEST_CASE("设备汇报的最大帧长小于协商 MTU 时下调 MTU") { +TEST_CASE("The MTU drops when the device's max frame size is below the negotiated MTU") { MockControlChannel channel; channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); @@ -564,7 +584,7 @@ TEST_CASE("设备汇报的最大帧长小于协商 MTU 时下调 MTU") { CHECK(machine.Parameters().mtu == 1400); } -TEST_CASE("MillisUntilNextPoll 在保活周期内返回正值") { +TEST_CASE("MillisUntilNextPoll returns a positive value within the keep-alive period") { MockControlChannel channel; channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); RecordingObserver observer; @@ -579,37 +599,39 @@ TEST_CASE("MillisUntilNextPoll 在保活周期内返回正值") { } // TEST_SUITE("rndis.state_machine") TEST_SUITE("rndis.state_machine_timeout") { - -// ★ 针对一类「mock 测不出来」的挂死缺陷的回归用例 ★ -// -// 缺陷:Poll() 里写了 WaitForNotification(0),意图是「不等待、只探一下」, -// 但 libusb 在 darwin 上把 timeout 同时作为 noDataTimeout 与 -// completionTimeout 传给 IOKit,**0 表示无限等待** —— -// 控制循环会永久卡死在 libusb_wait_for_event 上,连 SIGTERM 都响应不了。 -// mock 之所以测不出来,是因为它对任何超时都立即返回。 -// 所以这里改为直接断言「传下去的超时值」本身。 - -TEST_CASE("绝不给 WaitForNotification 传 0(0 在 darwin 上是无限等待)") { - MockControlChannel channel; - channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); - RecordingObserver observer; - // 刻意用把 response_poll_interval_millis 设成 0 的配置 —— 这正是当初 - // Transact() 里 min(control_timeout, interval*4) 算出 0 的那条路径。 - auto config = FastConfig(); - config.response_poll_interval_millis = 0; - StateMachine machine(channel, observer, config); - REQUIRE(machine.Start().has_value()); - REQUIRE(machine.Poll().has_value()); - - REQUIRE_FALSE(channel.NotificationTimeouts().empty()); - for (const std::uint32_t timeout : channel.NotificationTimeouts()) { - CHECK_MESSAGE(timeout > 0, - "WaitForNotification 收到了 0 —— 在 darwin 上等于无限等待,会卡死控制线程"); + // Regression case for a class of hang defects that the mock cannot catch. + // + // Defect: Poll() called WaitForNotification(0), intending "do not wait, just + // probe", but on darwin libusb passes the timeout to IOKit as both + // noDataTimeout and completionTimeout, and **0 means wait + // forever** -- the control loop would wedge permanently in + // libusb_wait_for_event, not even answering SIGTERM. + // The mock cannot catch this because it returns immediately for any timeout. + // So this asserts the timeout value that is handed down instead. + + TEST_CASE("Never pass 0 to WaitForNotification (0 means an infinite wait on darwin)") { + MockControlChannel channel; + channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); + RecordingObserver observer; + // Deliberately use a configuration whose response_poll_interval_millis is + // 0 -- that is exactly the path on which Transact()'s + // min(control_timeout, interval*4) computed 0. + auto config = FastConfig(); + config.response_poll_interval_millis = 0; + StateMachine machine(channel, observer, config); + REQUIRE(machine.Start().has_value()); + REQUIRE(machine.Poll().has_value()); + + REQUIRE_FALSE(channel.NotificationTimeouts().empty()); + for (const std::uint32_t timeout : channel.NotificationTimeouts()) { + CHECK_MESSAGE(timeout > 0, + "WaitForNotification received 0 -- on darwin that means an infinite wait and " + "would wedge the control thread"); + } } -} -TEST_CASE("kProbeOnlyTimeoutMillis 本身必须非零") { - CHECK(kProbeOnlyTimeoutMillis > 0); -} + TEST_CASE("kProbeOnlyTimeoutMillis itself must be non-zero") { + CHECK(kProbeOnlyTimeoutMillis > 0); + } } // TEST_SUITE("rndis.state_machine_timeout") diff --git a/tests/test_version.cc b/tests/test_version.cc index 41fde1b..edea9a3 100644 --- a/tests/test_version.cc +++ b/tests/test_version.cc @@ -1,18 +1,18 @@ -// 版本信息的冒烟测试:确认 CMake 的版本注入链路是通的。 +// Smoke tests for the version info: confirms the CMake version-injection +// pipeline is wired up. #include #include "tetherkit/version.h" TEST_SUITE("version") { + TEST_CASE("Version number is not all zeros") { + const auto v = tetherkit::GetVersion(); + CHECK((v.major != 0 || v.minor != 0 || v.patch != 0)); + } -TEST_CASE("版本号非全零") { - const auto v = tetherkit::GetVersion(); - CHECK((v.major != 0 || v.minor != 0 || v.patch != 0)); -} - -TEST_CASE("版本串包含项目名") { - const std::string_view s = tetherkit::GetVersionString(); - CHECK(s.find("TetherKit") != std::string_view::npos); -} + TEST_CASE("Version string contains the project name") { + const std::string_view s = tetherkit::GetVersionString(); + CHECK(s.find("TetherKit") != std::string_view::npos); + } } // TEST_SUITE("version") diff --git a/third_party/CMakeLists.txt b/third_party/CMakeLists.txt index a3ad4f2..cb1574b 100644 --- a/third_party/CMakeLists.txt +++ b/third_party/CMakeLists.txt @@ -1,10 +1,12 @@ -# third_party —— 内置(vendored)第三方依赖 +# third_party -- vendored third-party dependencies # -# 刻意选择 vendoring 而非 FetchContent:configure 阶段不联网,保证任何时候、 -# 任何网络环境下 `cmake -B build` 都可重复成功。 +# Vendoring is a deliberate choice over FetchContent: the configure step stays +# offline, so `cmake -B build` succeeds reproducibly at any time and under any +# network conditions. -# doctest 2.4.12 —— 单头文件测试框架。 -# 选它而不是 GoogleTest/Catch2 的理由:单个头文件、编译速度快、断言表达力足够、 -# 原生支持 SUBCASE 做参数化、且能安全用于多线程测试断言。 +# doctest 2.4.12 -- a single-header test framework. +# Why it over GoogleTest/Catch2: one header, fast to compile, expressive enough +# assertions, native SUBCASE support for parameterized cases, and safe to use +# for assertions in multithreaded tests. add_library(doctest INTERFACE) target_include_directories(doctest SYSTEM INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/doctest") From e12b049c729a99961bfa2dcc841d1e9218667bf3 Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:56:06 +0530 Subject: [PATCH 4/6] docs(i18n)!: translate the docs, drop the Chinese README docs/BENCHMARKS.md is half generated: regenerating it loses the root-measured numbers and the hand-written analysis. --- AGENTS.md | 1083 +++++++++++---------------------- README.md | 23 +- README.zh-CN.md | 379 ------------ docs/BENCHMARKS.md | 704 ++++++++++++---------- docs/DESIGN.md | 564 ++++++++++-------- docs/GUI-ARCHITECTURE.md | 1226 +++++++++++++++++++++++--------------- docs/GUI-SPIKE.md | 614 +++++++++++-------- docs/PERFORMANCE.md | 303 ++++++---- docs/RNDIS-PROTOCOL.md | 530 ++++++++-------- 9 files changed, 2625 insertions(+), 2801 deletions(-) delete mode 100644 README.zh-CN.md diff --git a/AGENTS.md b/AGENTS.md index b99bc9a..9023310 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,857 +1,488 @@ -# AGENTS.md —— TetherKit 实现备忘 +# AGENTS.md -- TetherKit implementation notes -> 本文件是给 AI agent(以及接手的人类)看的**工作记忆**。 -> 每完成一个提交都要更新对应章节。开始任何工作前先读一遍本文件,避免重复踩坑。 +> This file is **working memory** for AI agents (and for any human taking over). +> Update the relevant section after every commit. Read this file before starting any work to avoid stepping on the same pitfalls twice. --- -## 1. 项目一句话 +## 1. One-sentence project summary -macOS **用户态** RNDIS 驱动:USB 侧用 libusb 与 RNDIS 设备(Android 手机 USB 网络共享等) -通话,网卡侧用 `feth` 虚拟网卡对 + BPF 直接读写原始以太帧,把设备变成一张系统可见的网卡。 +A macOS **userspace** RNDIS driver: on the USB side it uses libusb to talk to RNDIS devices (Android USB tethering, etc.); on the network side it uses a `feth` virtual interface pair plus BPF to read and write raw Ethernet frames directly, turning the device into a network interface visible to the system. --- -## 2. 已实测确认的环境事实(**不要重复验证,直接引用**) +## 2. Measured environment facts (confirmed on real hardware, **do not re-verify, cite directly**) -实测机器:macOS 26.5.1 (Darwin 25.5.0)、Apple Silicon arm64、Apple clang 21.0.0。 +Measurement machine: macOS 26.5.1 (Darwin 25.5.0), Apple Silicon arm64, Apple clang 21.0.0. -### 2.1 工具链 +### 2.1 Toolchain -| 事实 | 结论 | 验证方式 | +| Fact | Conclusion | How verified | |---|---|---| -| Apple clang 21 的 C++23 | **可用**:`std::expected`、`std::byteswap`、`std::format`、`jthread`、`stop_token`、`latch`、`counting_semaphore` 全部通过编译+运行 | 实测编译运行 | -| `std::expected` / `std::byteswap` 在 `-std=c++20` 下 | **不可用**,是 C++23 库特性。项目因此定为 C++23 | 实测编译报错 | -| `std::format` 浮点格式化 | 依赖 libc++ 的 `std::to_chars`,带 availability 标注,**部署目标必须 ≥ macOS 13.3**,否则编译失败 | 实测 `-mmacosx-version-min=13.0` 报 `'to_chars' is unavailable: introduced in macOS 13.3` | -| `std::hardware_destructive_interference_size` | Apple libc++ **未提供**。必须自己定义缓存行常量 | 实测编译报错 | -| **缓存行大小** | **128 字节**(不是 64!)`hw.cachelinesize: 128`。SPSC 队列的 false-sharing 填充必须按 128 对齐 | `sysctl hw.cachelinesize` | -| L1D 缓存 | 65536 字节 | `sysctl hw.l1dcachesize` | -| CPU | 10 逻辑核,其中 **4 个性能核**(`hw.perflevel0.logicalcpu = 4`)。数据路径线程需要用 QoS 争取性能核 | `sysctl hw.ncpu hw.perflevel0.logicalcpu` | -| `-mcpu=apple-m1` / `-mcpu=native` | 都可用;但默认**不开**(见 `cmake/Optimizations.cmake` 里的取舍说明) | 实测编译 | -| ninja | **未安装**,用默认 Unix Makefiles 生成器 | `which ninja` | +| Apple clang 21 C++23 | **Usable**: `std::expected`, `std::byteswap`, `std::format`, `jthread`, `stop_token`, `latch`, and `counting_semaphore` all compile and run | Compiled and run on real hardware | +| `std::expected` / `std::byteswap` under `-std=c++20` | **Unavailable**; they are C++23 library features. The project is therefore set to C++23 | Compilation error on real hardware | +| `std::format` floating-point formatting | Depends on libc++ `std::to_chars` with availability annotations; the **deployment target must be >= macOS 13.3**, otherwise compilation fails | `-mmacosx-version-min=13.0` produced `'to_chars' is unavailable: introduced in macOS 13.3` | +| `std::hardware_destructive_interference_size` | **Not provided** by Apple libc++. The cache-line constant must be defined ourselves | Compilation error on real hardware | +| **Cache line size** | **128 bytes** (not 64!) `hw.cachelinesize: 128`. False-sharing padding in the SPSC queue must be aligned to 128 | `sysctl hw.cachelinesize` | +| L1D cache | 65536 bytes | `sysctl hw.l1dcachesize` | +| CPU | 10 logical cores, including **4 performance cores** (`hw.perflevel0.logicalcpu = 4`). Data-path threads need QoS to compete for performance cores | `sysctl hw.ncpu hw.perflevel0.logicalcpu` | +| `-mcpu=apple-m1` / `-mcpu=native` | Both work, but **off by default** (see the trade-off notes in `cmake/Optimizations.cmake`) | Compiled on real hardware | +| ninja | **Not installed**; the default Unix Makefiles generator is used | `which ninja` | | CMake | 4.3.3 | `cmake --version` | ### 2.2 libusb -| 事实 | 结论 | +| Fact | Conclusion | |---|---| -| 版本 | 1.0.30.12037,`/opt/homebrew/opt/libusb` | -| pkg-config | 可用。注意头文件目录是 `.../include/libusb-1.0`(非常规),`FindLibUSB.cmake` 已处理 | -| hotplug | `libusb_has_capability(LIBUSB_CAP_HAS_HOTPLUG)` 返回 **1**,支持 | -| 链接依赖 | darwin 后端需要 `IOKit`、`CoreFoundation`、`Security` 三个 framework | -| **本机 USB 设备** | 立项时为 **0**(`libusb_get_device_list` 与 `ioreg -c IOUSBHostDevice` 都是 0 条),架构因此按「USB 逻辑必须能用 mock 后端离线测试」来设计。后来接上过真实 RNDIS 设备做验证(见第 6 节),但**这个设计约束仍然有效** —— 不要假设跑测试时一定有设备 | +| Version | 1.0.30.12037, `/opt/homebrew/opt/libusb` | +| pkg-config | Available. Note the header directory is `.../include/libusb-1.0` (non-standard); `FindLibUSB.cmake` handles it | +| hotplug | `libusb_has_capability(LIBUSB_CAP_HAS_HOTPLUG)` returns **1**, supported | +| Link dependencies | The darwin backend needs the `IOKit`, `CoreFoundation`, and `Security` frameworks | +| **USB devices on this machine** | **0** at project start (`libusb_get_device_list` and `ioreg -c IOUSBHostDevice` both returned 0 entries), so the architecture was designed around "USB logic must be testable offline with a mock backend". A real RNDIS device was connected later for verification (see section 6), but **this design constraint still holds** -- do not assume a device is present when tests run | -### 2.3 BPF(Darwin) +### 2.3 BPF (Darwin) -| 事实 | 结论 | +| Fact | Conclusion | |---|---| -| **零拷贝 BPF** | **不存在**。SDK 的 `net/bpf.h` 里**没有** `BIOCSETZBUF` / `BIOCGETZMAX` / `BIOCROTZBUF`(FreeBSD 有,Darwin 没有)。只能用经典 BPF:大 `BIOCSBLEN` + 批量 `read()` | -| 可用 ioctl 全集 | `BIOCGBLEN`(102R) `BIOCSBLEN`(102WR) `BIOCSETF`(103) `BIOCFLUSH`(104) `BIOCPROMISC`(105) `BIOCGDLT`(106) `BIOCGETIF`(107) `BIOCSETIF`(108) `BIOCSRTIMEOUT`(109) `BIOCGRTIMEOUT`(110) `BIOCGSTATS`(111) `BIOCIMMEDIATE`(112) `BIOCVERSION`(113) `BIOCGRSIG`(114) `BIOCSRSIG`(115) `BIOCGHDRCMPLT`(116) `BIOCSHDRCMPLT`(117) `BIOCGSEESENT`(118) `BIOCSSEESENT`(119) `BIOCSDLT`(120) `BIOCGDLTLIST`(121) `BIOCSETFNR`(126) | -| `struct bpf_hdr` | `{ struct BPF_TIMEVAL bh_tstamp; bpf_u_int32 bh_caplen; bpf_u_int32 bh_datalen; u_short bh_hdrlen; }` —— 遍历时**必须**用 `bh_hdrlen` 而非 `sizeof(bpf_hdr)` | -| 对齐宏 | `BPF_ALIGNMENT = sizeof(int32_t) = 4`,`BPF_WORDALIGN(x) = ((x)+3) & ~3` | -| 设备节点 | `/dev/bpf0..3` 存在(Darwin 按需克隆更多节点) | -| **批量写** | ✅ **支持**(macOS 14+):私有 ioctl `BIOCSBATCHWRITE = _IOW('B',143,int) = 0x8004428f`。缓冲格式与 read 对称(连续的 `bpf_hdr + 帧`,每条按 `BPF_WORDALIGN` 对齐),前置条件是 `BIOCSHDRCMPLT=1`。macOS 13 及更早**没有**此 ioctl → 必须运行时特性探测,失败回落逐帧 write | -| 私有 ioctl 编号(实测核对) | `BIOCSBATCHWRITE`=0x8004428f、`BIOCSNOTSTAMP`=0x80044291(关时间戳,省每帧一次 microtime)、`BIOCSWRITEMAX`=0x8004428c、`BIOCSDIRECTION`=0x8004428a、`BIOCSHEADDROP`=0x80044280。macOS 26 把它们从 `net/bpf.h` 挪到了 `net/bpf_private.h`(**SDK 未提供该文件**) | -| `BIOCSBLEN` 上限 | `sysctl debug.bpf_bufsize_cap` = **33554432(32 MiB)**。超限**不报错**,静默截断并通过 `_IOWR` 把实际值**写回参数** | -| `read()` 缓冲长度 | **必须精确等于** `bd_bufsize`,否则 `bpfread` 开头就返回 `EINVAL`。必须用 `BIOCSBLEN` 的写回值 | -| `BIOCSHDRCMPLT` | **必须设为 1**。=0 时 `bpfwrite` 会剥掉前 14 字节重建帧头(源 MAC 被驱动改写);=1 才走 `DLIL_OUTPUT_FLAGS_RAW` 原样透传。批量写也硬性要求它是 1 | -| 单帧写入长度上限 | `BPF_WRITE_LEEWAY = 18`,`hdrcmplt=1` 时整帧长度必须 ≤ 接口 MTU + 18(MTU=1500 → 1518) | -| `BIOCSRTIMEOUT` 分辨率 | **10 ms**(内核存 `tvtohz(tv)-1` 个 tick,实测 `kern.clockrate hz=100`)。传 `{0,0}` 会变成**永久阻塞** | -| 最优读取模型 | `BIOCIMMEDIATE=1` + **专用线程阻塞 `read()`**,不要用 kqueue。immediate 下每来一包就唤醒,`read()` 醒来时一次性交付期间累积的全部包(低速低延迟、高速自动大批量,行为类似 NAPI),每批只一次系统调用;kqueue 的就绪判据完全一样却要多一次 `kevent()` | -| `/dev/bpf` 克隆节点 | **不存在**(实测 `ls /dev/bpf` → No such file)。必须遍历 `/dev/bpf%d`:`EBUSY` 试下一个、`ENOENT` 到上限。打开当前最后一个节点时内核会按需再造一个。上限 `sysctl debug.bpf_maxdevices` = 256 | -| `access_bpf` 组 | macOS **没有**(那是 FreeBSD 的做法,实测 `dscl . -list /Groups` 无匹配)。`/dev/bpf*` 是 `0600 root:wheel`,只能 root | - -### 2.4 feth(if_fake) - -| 事实 | 结论 | +| **Zero-copy BPF** | **Does not exist**. The SDK's `net/bpf.h` has **no** `BIOCSETZBUF` / `BIOCGETZMAX` / `BIOCROTZBUF` (FreeBSD has them, Darwin does not). Only classic BPF is possible: large `BIOCSBLEN` + batched `read()` | +| Full set of available ioctls | `BIOCGBLEN`(102R) `BIOCSBLEN`(102WR) `BIOCSETF`(103) `BIOCFLUSH`(104) `BIOCPROMISC`(105) `BIOCGDLT`(106) `BIOCGETIF`(107) `BIOCSETIF`(108) `BIOCSRTIMEOUT`(109) `BIOCGRTIMEOUT`(110) `BIOCGSTATS`(111) `BIOCIMMEDIATE`(112) `BIOCVERSION`(113) `BIOCGRSIG`(114) `BIOCSRSIG`(115) `BIOCGHDRCMPLT`(116) `BIOCSHDRCMPLT`(117) `BIOCGSEESENT`(118) `BIOCSSEESENT`(119) `BIOCSDLT`(120) `BIOCGDLTLIST`(121) `BIOCSETFNR`(126) | +| `struct bpf_hdr` | `{ struct BPF_TIMEVAL bh_tstamp; bpf_u_int32 bh_caplen; bpf_u_int32 bh_datalen; u_short bh_hdrlen; }` -- traversal **must** use `bh_hdrlen`, not `sizeof(bpf_hdr)` | +| Alignment macros | `BPF_ALIGNMENT = sizeof(int32_t) = 4`, `BPF_WORDALIGN(x) = ((x)+3) & ~3` | +| Device nodes | `/dev/bpf0..3` exist (Darwin clones more nodes on demand) | +| **Batch write** | Supported (macOS 14+): private ioctl `BIOCSBATCHWRITE = _IOW('B',143,int) = 0x8004428f`. The buffer format is symmetric with read (consecutive `bpf_hdr + frame`, each aligned by `BPF_WORDALIGN`), with the prerequisite `BIOCSHDRCMPLT=1`. macOS 13 and earlier **do not have** this ioctl -> runtime feature detection is required, falling back to per-frame write on failure | +| Private ioctl numbers (verified on real hardware) | `BIOCSBATCHWRITE`=0x8004428f, `BIOCSNOTSTAMP`=0x80044291 (disable timestamps, saving one microtime per frame), `BIOCSWRITEMAX`=0x8004428c, `BIOCSDIRECTION`=0x8004428a, `BIOCSHEADDROP`=0x80044280. macOS 26 moved them from `net/bpf.h` to `net/bpf_private.h` (**that file is not provided by the SDK**) | +| `BIOCSBLEN` upper bound | `sysctl debug.bpf_bufsize_cap` = **33554432 (32 MiB)**. Exceeding it does **not** report an error; it is silently clamped and the actual value is **written back into the argument** via `_IOWR` | +| `read()` buffer length | **Must exactly equal** `bd_bufsize`, otherwise `bpfread` returns `EINVAL` at the start. The written-back value from `BIOCSBLEN` must be used | +| `BIOCSHDRCMPLT` | **Must be set to 1**. =0 makes `bpfwrite` strip the first 14 bytes and rebuild the frame header (the source MAC is rewritten by the driver); =1 takes the `DLIL_OUTPUT_FLAGS_RAW` path and passes the frame through unchanged. Batch write also requires it to be 1 | +| Single-frame write length limit | `BPF_WRITE_LEEWAY = 18`; with `hdrcmplt=1` the total frame length must be <= interface MTU + 18 (MTU=1500 -> 1518) | +| `BIOCSRTIMEOUT` resolution | **10 ms** (the kernel stores `tvtohz(tv)-1` ticks; `kern.clockrate hz=100` measured on real hardware). Passing `{0,0}` becomes a **permanent block** | +| Optimal read model | `BIOCIMMEDIATE=1` + a **dedicated thread blocking on `read()`**, do not use kqueue. With immediate, every arriving packet wakes the reader, and when `read()` wakes it delivers all packets accumulated during that interval at once (low latency at low rate, automatically large batches at high rate, behavior similar to NAPI), with only one system call per batch; kqueue has the same readiness criterion but costs an extra `kevent()` | +| `/dev/bpf` clone node | **Does not exist** (`ls /dev/bpf` -> No such file, measured on real hardware). `/dev/bpf%d` must be tried in order: try the next on `EBUSY`, stop at the limit on `ENOENT`. Opening the current last node causes the kernel to create one more on demand. Limit `sysctl debug.bpf_maxdevices` = 256 | +| `access_bpf` group | macOS **does not have one** (that is the FreeBSD approach; `dscl . -list /Groups` had no match on real hardware). `/dev/bpf*` is `0600 root:wheel`, so only root can open it | + +### 2.4 feth (if_fake) + +| Fact | Conclusion | |---|---| -| 是否存在 | **存在**,`sysctl net.link.fake.*` 可见 | -| 关键 sysctl | `net.link.fake.max_mtu = 2048`、`tx_headroom = 32`、`buflet_size = 512`、`qset_cnt = 4`、`link_layer_aggregation_factor = 96` | -| 创建权限 | **需要 root**:非 root 下 `ifconfig feth0 create` 返回 `SIOCIFCREATE2: Operation not permitted` | -| `struct ifdrv` | **不在**公开 SDK 的 `net/if.h` 中,必须自行声明。带 `#pragma pack(4)`,LP64 下 `sizeof == 40`(偏移 16/24/32)。**大小参与 ioctl 编号计算**,算错就得到不存在的 ioctl 号 → 已用 `static_assert` 钉死。实测 `SIOCSDRVSPEC = 0x8028697b`、`SIOCGDRVSPEC = 0xc028697b` | -| `net/if_fake_var.h` | **不在**公开 SDK 中。`struct if_fake_request` = `uint64_t reserved[4]`(32 字节,**内核校验必须全零**)+ 128 字节 union,**总 160 字节**。`IF_FAKE_S_CMD_SET_PEER = 1`、`IF_FAKE_G_CMD_GET_PEER = 1` | -| 私有 ABI 的版本风险 | **极低,无需降级到 ifconfig**:`if_fake_var.h` 在 xnu-7195(macOS 11) → xnu-12377(macOS 26) 的所有发布 tag 下文件 md5 完全相同,从未变动。Apple 自己的 `ifconfig fethN peer fethM` 走的就是这套 ABI | -| SET_PEER 的内核校验 | 五项,任一不满足返回 `EINVAL`:① `ifd_len >= 160`;② `reserved` 全零;③ peer 必须也是 feth(`ifnet_name()=="feth"` 且 `IFT_ETHER`);④ 双方都不能已有 peer;⑤ 需要 root | -| **数据流向语义**(已对照 `feth_output_common()` 源码确认) | 主机从 feth0 发出的帧 → 在 feth1 上是 **input** 方向;我们向 feth1 的 BPF `write()` 一帧 → 走 feth1 的 output → 进入 feth0 的 **input** → 被主机 IP 栈收到,**不会** loopback 回 feth1。所以 BPF 只挂 feth1 一个描述符就能同时收发 | -| `BIOCSSEESENT=0` 的作用 | → `bd_direction = BPF_D_IN`,恰好滤掉**我们自己写进去的帧**(它们在 feth1 上是 output 方向)。**不设会形成回环** | -| `IFF_UP` 要求 | `bpfwrite` 里有硬检查 `if ((ifp->if_flags & IFF_UP) == 0) return ENETDOWN` → feth1 必须 UP;feth0 也必须 UP 才能让 IP 栈处理收到的帧 | -| **创建期 sysctl 快照** | ⚠️ `hwcsum` / `fcs` / `tso_support` / `lro` / `trailer_length` / `separate_frame_header` / `max_mtu` 等开关在 `feth_clone_create()` 那一刻被快照进接口,**创建后再改 sysctl 无效**。若 `hwcsum=1`,我们读到的帧校验和是留给硬件算的假值!本机实测默认全部为 0(正是我们要的),但代码里仍在创建前显式校验(`VerifyFethSysctls()`) | -| 内核分配的 MAC | `'f','e','t','h', unit>>8, unit&0xff` → feth0 = `66:65:74:68:00:00`(0x66 的 bit1=1 → 本地管理地址,合法) | -| MAC 设置策略 | 应把**系统侧**(feth0)的 MAC 设为设备汇报的 `OID_802_3_PERMANENT_ADDRESS`(RNDIS 语义下设备就是这块网卡,对端 ARP/DHCP 都按它建);**驱动侧必须保留内核分配的不同 MAC**,否则两侧 IPv6 链路本地地址相同会触发 DAD 冲突。改 MAC 必须在 `IFF_UP` 之前 | -| `BIOCPROMISC` | 对 feth **不需要**:`feth_output_common` 无条件把帧投给 peer 并 tap,不做 MAC 过滤,能否读到只由方向决定 | -| DHCP | `sudo ipconfig set feth0 DHCP`(临时服务,只活到下次网络配置变更,不出现在系统设置里)。拆除 `sudo ipconfig set feth0 NONE`。`networksetup` 用不了 —— feth 不在 `SCNetworkInterface` 列表里 | -| 性能上限警示 | 社区报告 feth 路径在超过约 5–8 Gbps 后会出现内核 mbuf 溢出并 panic。对本项目风险很低:RNDIS over USB 2.0 HS 实测约 200–300 Mbps,USB 3 下也难超 1–2 Gbps | -| 相关 ioctl(**在**公开 `sys/sockio.h` 中) | `SIOCSIFFLAGS`(i,16) `SIOCGIFFLAGS`(i,17) `SIOCSIFMTU`(i,52) `SIOCSIFLLADDR`(i,60) `SIOCIFCREATE`(i,120) `SIOCIFDESTROY`(i,121) `SIOCIFCREATE2`(i,122) `SIOCSDRVSPEC`(i,123) `SIOCGDRVSPEC`(i,123) | +| Present | **Yes**, visible via `sysctl net.link.fake.*` | +| Key sysctls | `net.link.fake.max_mtu = 2048`, `tx_headroom = 32`, `buflet_size = 512`, `qset_cnt = 4`, `link_layer_aggregation_factor = 96` | +| Creation permission | **Requires root**: as a non-root user, `ifconfig feth0 create` returns `SIOCIFCREATE2: Operation not permitted` | +| `struct ifdrv` | **Not in** the public SDK's `net/if.h`; it must be declared manually. With `#pragma pack(4)`, `sizeof == 40` on LP64 (offsets 16/24/32). **The size participates in ioctl number calculation**; getting it wrong yields a non-existent ioctl number -> pinned down with `static_assert`. Measured `SIOCSDRVSPEC = 0x8028697b`, `SIOCGDRVSPEC = 0xc028697b` | +| `net/if_fake_var.h` | **Not in** the public SDK. `struct if_fake_request` = `uint64_t reserved[4]` (32 bytes, **the kernel requires all zeros**) + a 128-byte union, **160 bytes total**. `IF_FAKE_S_CMD_SET_PEER = 1`, `IF_FAKE_G_CMD_GET_PEER = 1` | +| Private ABI version risk | **Very low, no need to fall back to ifconfig**: the md5 of `if_fake_var.h` is identical across every release tag from xnu-7195 (macOS 11) to xnu-12377 (macOS 26), never changed. Apple's own `ifconfig fethN peer fethM` takes this same ABI path | +| SET_PEER kernel checks | Five checks; failure of any returns `EINVAL`: (1) `ifd_len >= 160`; (2) `reserved` all zero; (3) the peer must also be feth (`ifnet_name()=="feth"` and `IFT_ETHER`); (4) neither side may already have a peer; (5) root is required | +| **Data-flow semantics** (confirmed against the `feth_output_common()` source) | A frame the host sends from feth0 -> is in the **input** direction on feth1; a frame we `write()` into feth1's BPF -> takes feth1's output path -> enters feth0's **input** -> is received by the host IP stack, and **does not** loop back to feth1. So BPF only needs to attach one descriptor to feth1 to both receive and transmit | +| Effect of `BIOCSSEESENT=0` | -> `bd_direction = BPF_D_IN`, which filters out exactly **the frames we ourselves wrote** (they are in the output direction on feth1). **Not setting it creates a loop** | +| `IFF_UP` requirement | `bpfwrite` has a hard check `if ((ifp->if_flags & IFF_UP) == 0) return ENETDOWN` -> feth1 must be UP; feth0 must also be UP so the IP stack can process received frames | +| **Sysctl snapshot at creation time** | Warning: `hwcsum` / `fcs` / `tso_support` / `lro` / `trailer_length` / `separate_frame_header` / `max_mtu` and similar switches are snapshotted into the interface at the moment `feth_clone_create()` runs; **changing the sysctls after creation has no effect**. If `hwcsum=1`, the frame checksums we read are placeholder values left for hardware to compute. On this machine the defaults were all 0 on real hardware (exactly what we want), but the code still validates them explicitly before creation (`VerifyFethSysctls()`) | +| Kernel-assigned MAC | `'f','e','t','h', unit>>8, unit&0xff` -> feth0 = `66:65:74:68:00:00` (bit 1 of 0x66 is set -> locally administered address, valid) | +| MAC-setting strategy | The **system side** (feth0) MAC should be set to the device-reported `OID_802_3_PERMANENT_ADDRESS` (in RNDIS semantics the device is this network interface; peers build ARP/DHCP state from it); the **driver side must keep its different kernel-assigned MAC**, otherwise the two sides would have identical IPv6 link-local addresses and trigger a DAD conflict. The MAC must be changed before `IFF_UP` | +| `BIOCPROMISC` | **Not needed** for feth: `feth_output_common` unconditionally delivers frames to the peer and taps without MAC filtering; whether a frame is readable is determined only by direction | +| DHCP | `sudo ipconfig set feth0 DHCP` (a temporary service that lives only until the next network configuration change and does not appear in System Settings). Teardown: `sudo ipconfig set feth0 NONE`. `networksetup` cannot be used -- feth is not in the `SCNetworkInterface` list | +| Performance ceiling warning | Community reports show the feth path can hit kernel mbuf overflow and panic above roughly 5-8 Gbps. Risk to this project is low: RNDIS over USB 2.0 HS measures about 200-300 Mbps, and even over USB 3 it is hard to exceed 1-2 Gbps | +| Relevant ioctls (**present** in the public `sys/sockio.h`) | `SIOCSIFFLAGS`(i,16) `SIOCGIFFLAGS`(i,17) `SIOCSIFMTU`(i,52) `SIOCSIFLLADDR`(i,60) `SIOCIFCREATE`(i,120) `SIOCIFDESTROY`(i,121) `SIOCIFCREATE2`(i,122) `SIOCSDRVSPEC`(i,123) `SIOCGDRVSPEC`(i,123) | | `IFNAMSIZ` | 16 | -### 2.5 开发环境的限制(影响测试策略) +### 2.5 Development-environment constraints (affect the test strategy) -- **无 root**:当前会话以 uid 501 运行,无法创建 feth、无法打开 `/dev/bpf*`。 - → 需要 root 的测试必须**可选、可跳过**,并在 CI/本地用 `TETHERKIT_ROOT_TESTS=1` 之类的开关控制。 -- **不保证有 USB 设备**:立项时开发机上一个都没有,后来才接上过真实设备做验证。 - → USB 后端必须抽象成接口,提供内存 loopback mock,端到端测试与吞吐基准都跑在 mock 上。 - 自动化测试**不得依赖设备在场**。 -- 验证状态与仍未验证的事项统一记录在本文件第 6 节。 +- **No root**: the current session runs as uid 501 and cannot create feth or open `/dev/bpf*`. + -> Tests that need root must be **optional and skippable**, controlled in CI and locally by a switch such as `TETHERKIT_ROOT_TESTS=1`. +- **No guarantee of a USB device**: at project start the development machine had none, and a real device was only connected later for verification. + -> The USB backend must be abstracted behind an interface with an in-memory loopback mock; end-to-end tests and throughput benchmarks run on the mock. + Automated tests **must not depend on a device being present**. +- Verification status and items still unverified are recorded together in section 6 of this file. --- -## 3. 目录结构 +## 3. Directory structure ``` TetherKit/ -├── AGENTS.md 本文件:agent 工作记忆 -├── README.md 用户向文档(**英文,GitHub 默认展示的那份**) -├── README.zh-CN.md 同上的中文版;两份内容对等,改一份要同步另一份 -├── CMakeLists.txt 顶层构建脚本 -├── .clang-format 代码格式(Google 基线,100 列) -├── .clang-tidy 静态检查与命名约定 +├── AGENTS.md This file: agent working memory +├── README.md User-facing documentation (English, the default shown on GitHub) +├── CMakeLists.txt Top-level build script +├── .clang-format Code formatting (Google baseline, 100 columns) +├── .clang-tidy Static checks and naming conventions ├── cmake/ -│ ├── FindLibUSB.cmake libusb-1.0 查找(pkg-config 优先 + Homebrew 回退) -│ ├── CompilerWarnings.cmake 警告选项 INTERFACE 目标 -│ ├── Sanitizers.cmake ASan/UBSan/TSan 开关 -│ └── Optimizations.cmake 数据路径优化选项与取舍说明 -├── include/tetherkit/ 公开头文件(按模块分子目录) -│ ├── common/messages.def **全部面向用户文案**(X-macro,中英两列) -│ └── capi/ C ABI —— 全项目唯一的 extern "C" 边界 -├── src/ 实现 -│ ├── version.cc.in CMake 注入版本号的模板 -│ ├── capi/ C ABI 实现(会话、网卡配置、日志、孤儿清理) -│ └── app/ 命令行入口 -├── gui/ SwiftUI 图形界面(SwiftPM 工程,见 docs/GUI-ARCHITECTURE.md) -│ ├── Sources/CTetherKit/ C ABI 的模块映射(头文件是符号链接) -│ ├── Sources/TetherKitIPC/ App 与 helper 共享:XPC 协议、模型、授权 -│ ├── Sources/TetherKitUIModel/ 侧栏页面模型(独立 target,测试可 @testable import) -│ ├── Sources/TetherKitCore/ C ABI 的 Swift 封装 -│ ├── Sources/TetherKitHelper/ 特权 helper(root,由 launchd 拉起) -│ ├── Sources/TetherKitApp/ SwiftUI 界面 -│ ├── Resources/ Info.plist、LaunchDaemon plist -│ └── Scripts/ 构建 / 安装 / 卸载脚本 -├── tests/ doctest 单元测试(单一二进制 + test-suite 过滤) -├── benchmarks/ 自带轻量 harness 的性能基准 -├── third_party/doctest/ vendored doctest 2.4.12 单头文件 -└── docs/ 设计文档、协议笔记、性能报告 +│ ├── FindLibUSB.cmake libusb-1.0 discovery (pkg-config first + Homebrew fallback) +│ ├── CompilerWarnings.cmake Warning options as an INTERFACE target +│ ├── Sanitizers.cmake ASan/UBSan/TSan switches +│ └── Optimizations.cmake Data-path optimization options and trade-off notes +├── include/tetherkit/ Public headers (subdirectories by module) +│ ├── common/messages.def **All user-facing strings** (X-macro, single English column) +│ └── capi/ C ABI -- the only extern "C" boundary in the project +├── src/ Implementation +│ ├── version.cc.in CMake-injected version-number template +│ ├── capi/ C ABI implementation (session, interface config, logging, orphan cleanup) +│ └── app/ Command-line entry point +├── gui/ SwiftUI graphical interface (SwiftPM project, see docs/GUI-ARCHITECTURE.md) +│ ├── Sources/CTetherKit/ Module map for the C ABI (headers are symlinks) +│ ├── Sources/TetherKitIPC/ Shared by App and helper: XPC protocol, models, authorization +│ ├── Sources/TetherKitUIModel/ Sidebar page model (separate target, @testable import in tests) +│ ├── Sources/TetherKitCore/ Swift wrapper around the C ABI +│ ├── Sources/TetherKitHelper/ Privileged helper (root, launched by launchd) +│ ├── Sources/TetherKitApp/ SwiftUI interface +│ ├── Resources/ Info.plist, LaunchDaemon plist +│ └── Scripts/ Build / install / uninstall scripts +├── tests/ doctest unit tests (single binary + test-suite filtering) +├── benchmarks/ Performance benchmarks with a lightweight in-tree harness +├── third_party/doctest/ Vendored doctest 2.4.12 single header +└── docs/ Design documents, protocol notes, performance reports ``` -依赖方向**严格单向**,禁止反向或循环: +Dependencies are **strictly one-way**; reverse or cyclic dependencies are forbidden: ``` -tk_common ←(无依赖) - ↑ -tk_rndis / tk_net / tk_usb ← tk_common - ↑ -tk_core ← common + rndis + net + usb - ↑ -tetherkit(可执行)← core -tk_capi(C ABI) ← core → 打包成共享库 libtetherkit - ↑ -gui/(Swift) ← libtetherkit +tk_common <- (no dependencies) + ^ +tk_rndis / tk_net / tk_usb <- tk_common + ^ +tk_core <- common + rndis + net + usb + ^ +tetherkit (executable) <- core +tk_capi (C ABI) <- core -> packaged as the shared library libtetherkit + ^ +gui/ (Swift) <- libtetherkit ``` -Swift 侧只能看见 `libtetherkit`,看不见任何 C++ 符号(导出符号白名单卡死在 -`_tk_*`)。C++ 层的接口用了 `std::expected` / `std::span` / 抽象基类, -Swift 的 C++ 互操作吞不下,所以 C ABI 这一层不可省。 +The Swift side can only see `libtetherkit`, never any C++ symbol (the export symbol allowlist is pinned to `_tk_*`). The C++ layer's interfaces use `std::expected` / `std::span` / abstract base classes, which Swift's C++ interop cannot consume, so the C ABI layer is not optional. --- -## 4. 代码规范(与 `.clang-tidy` 中的 CheckOptions 一致) +## 4. Code conventions (consistent with CheckOptions in `.clang-tidy`) -| 类别 | 约定 | 例 | +| Category | Convention | Example | |---|---|---| -| 命名空间 | `lower_case` | `tetherkit`、`tetherkit::rndis` | -| 类型(class/struct/enum/别名) | `CamelCase` | `RndisStateMachine`、`BpfLink` | -| 函数与方法 | `CamelCase` | `SendEncapsulatedCommand()` | -| 局部变量与参数 | `lower_case` | `frame_len`、`max_transfer_size` | -| 私有/保护成员变量 | `lower_case_` 后缀下划线 | `handle_`、`request_id_` | -| 常量(全局/constexpr/枚举值) | `k` + `CamelCase` | `kMaxFrameSize`、`kCacheLineSize` | -| 宏 | `UPPER_CASE`(尽量不用宏) | | -| 文件名 | `snake_case`,实现用 `.cc`,头用 `.h` | `spsc_ring.h`、`bpf_link.cc` | +| Namespace | `lower_case` | `tetherkit`, `tetherkit::rndis` | +| Types (class/struct/enum/alias) | `CamelCase` | `RndisStateMachine`, `BpfLink` | +| Functions and methods | `CamelCase` | `SendEncapsulatedCommand()` | +| Local variables and parameters | `lower_case` | `frame_len`, `max_transfer_size` | +| Private/protected member variables | `lower_case_` with trailing underscore | `handle_`, `request_id_` | +| Constants (global/constexpr/enum values) | `k` + `CamelCase` | `kMaxFrameSize`, `kCacheLineSize` | +| Macros | `UPPER_CASE` (avoid macros when possible) | | +| File names | `snake_case`, `.cc` for implementation, `.h` for headers | `spsc_ring.h`, `bpf_link.cc` | -错误处理分层: +Error handling by layer: -- **初始化 / 控制路径**:`std::expected`,错误可携带 errno / libusb 错误码 / 上下文串。 -- **数据热路径**:**绝不**返回 `expected`、绝不抛异常、绝不分配。用返回计数 + 原子统计计数器表达失败。 -- 全程 `-fno-exceptions`?**不**关异常(doctest 需要),但项目自身代码不 `throw`。 +- **Initialization / control path**: `std::expected`, where the error can carry errno / libusb error code / context string. +- **Data hot path**: **never** return `expected`, never throw, never allocate. Use return counts plus atomic statistics counters to express failure. +- `-fno-exceptions` everywhere? **No** (doctest needs exceptions), but the project's own code does not `throw`. -### 文案与多语言(中 / 英) +### User-facing strings and language -**面向用户的文字一律不写字面量**,全部走文案表。两套实现,同一个心智模型: +**User-facing text is never written as a literal**; it all goes through the message table. Two implementations, the same mental model: -| | C++(库 + 命令行) | Swift(GUI + helper) | +| | C++ (library + CLI) | Swift (GUI + helper) | |---|---|---| -| 文案表 | `include/tetherkit/common/messages.def`(X-macro,展开成枚举 + 两张表) | `gui/Sources/TetherKitIPC/LocalizedStrings.swift`(穷尽 switch) | -| 取文案 | `Tr(Msg::kFoo, args...)`,参数与 `std::format` 一致 | `L(.foo, args...)`,`String(format:)` 的 printf 风格 | -| 打日志 | `TETHERKIT_INFO_TR(Msg::kFoo, ...)` 等一组宏 | —— | -| 不带参数 | `Text(Msg::kFoo)` 返回 `string_view`,不分配 | `L10n.text(.foo)` | -| 漏一种语言 | 表长断言 + `common.i18n` 用例 | **编译不过**(switch 穷尽) | -| 占位符对不上 | `common.i18n` 逐条核对下标与类型 | `LocalizationTests` 逐条核对位置与类型 | - -三条硬规矩: - -1. **热路径禁用。** `Tr()` / `L()` 都会分配并格式化,和 `error.h` 一样只允许出现在 - 初始化与控制路径上。热路径要文字时用 `Text(Msg::kFoo)`(纯查表,noexcept)。 -2. **两种语言的占位符必须一一对应**(个数、下标、类型)。语序不同就用显式编号 - 重排:C++ 是 `{0}`/`{1}`,Swift 是 `%1$@`/`%2$ld`。 -3. **Swift 侧整数一律 `%ld` 并在调用点转 `Int`。** `%d` 只取 64 位实参的低 32 位。 - -语言从哪里来: - -- 命令行:`--lang zh|en|auto`,缺省按 `TETHERKIT_LANG` → `LC_ALL` → `LC_MESSAGES` - → `LANG` 依次推断。**语言在解析参数之前就定下来**,所以帮助文本与参数错误 - 本身也是目标语言。⚠️ sudo 未必透传这些变量,那时要显式写 `--lang`。 -- GUI:App 菜单与菜单栏面板里的语言开关(跟随系统 / 中文 / English),存 - UserDefaults 的 `TetherKitLanguagePreference`。 - -**切语言时必须同步三处,缺一处就会出现「界面英文、日志中文」**:Swift 文案表 -(`L10n.apply`)、libtetherkit(`tk_set_language`)、helper(XPC 的 `setLanguage` -—— 它以 root 跑在 launchd 下,看不到用户偏好)。三处都在 `AppModel.applyLanguage` -里一起做,别在别处单独调其中一个。 +| Message table | `include/tetherkit/common/messages.def` (X-macro, expanded into an enum + one table) | `gui/Sources/TetherKitIPC/LocalizedStrings.swift` (exhaustive switch) | +| Look up a string | `Tr(Msg::kFoo, args...)`, arguments match `std::format` | `L(.foo, args...)`, `String(format:)` printf style | +| Log | The `TETHERKIT_INFO_TR(Msg::kFoo, ...)` family of macros | -- | +| No arguments | `Text(Msg::kFoo)` returns `string_view`, no allocation | `L10n.text(.foo)` | +| Missing a language | Table-length assertion + `common.i18n` case | **Fails to compile** (exhaustive switch) | +| Placeholder mismatch | `common.i18n` checks indices and types per entry | `LocalizationTests` checks position and type per entry | + +Three hard rules: + +1. **Forbidden on the hot path.** `Tr()` / `L()` both allocate and format; like `error.h`, they may appear only on the initialization and control paths. When the hot path needs text, use `Text(Msg::kFoo)` (pure table lookup, noexcept). +2. **Placeholders must correspond one-to-one across languages** (count, index, type). When word order differs, reorder with explicit numbering: `{0}`/`{1}` in C++, `%1$@`/`%2$ld` in Swift. +3. **On the Swift side integers are always `%ld` and converted to `Int` at the call site.** `%d` takes only the low 32 bits of a 64-bit argument. + +Where the language comes from: + +- CLI: `--lang en|auto`; when omitted, inferred in order from `TETHERKIT_LANG` -> `LC_ALL` -> `LC_MESSAGES` -> `LANG`. **The language is fixed before arguments are parsed**, so help text and argument errors themselves are in the target language. Warning: sudo does not necessarily pass these variables through; in that case pass `--lang` explicitly. Any other value is rejected with "unknown language ...; English is the only language shipped today". +- GUI: the language switch in the App menu and the menu bar panel (Follow system / English), stored in the `TetherKitLanguagePreference` UserDefaults key. + +**All three places must be updated together when the language changes; missing any one leaves "the UI in English, the logs in Chinese"**: the Swift string table (`L10n.apply`), libtetherkit (`tk_set_language`), and the helper (XPC `setLanguage` -- it runs as root under launchd and cannot see the user preference). All three are done together in `AppModel.applyLanguage`; do not call any one of them alone elsewhere. + +The string table currently has one column (English). The CLI accepts `--lang en` (and `auto`, which also resolves to English) and rejects every other value. The GUI menu offers "Follow system" and "English". The multi-language plumbing (the `Language` enum, `TextIn`, `ParseLanguage`, the C ABI `tk_set_language`, the helper's `setLanguage` XPC method, `L10n.apply`) is deliberately kept so that adding a language later stays a bounded five-touch change; the checklist lives at the top of `include/tetherkit/common/messages.def`. --- -## 5. 实现进度 +## 5. Implementation progress -图例:`✅ 已完成` `🚧 进行中` `⬜ 未开始` +Legend: `Done` `In progress` `Not started` -| # | 提交 | 状态 | 备注 | +| # | Commit | Status | Notes | |---|---|---|---| -| 1 | `chore: 初始化项目脚手架与构建系统` | ✅ | CMake + 规范配置 + vendored doctest + 版本注入 | -| 2 | `feat(common): 基础设施层` | ✅ | 错误/日志/字节序/无锁队列/统计/线程 QoS | -| 3 | `feat(bench): 基准 harness 与微基准` | ✅ | 自带 harness,输出 Markdown 直接进 docs/ | -| 4 | `feat(rndis): RNDIS 协议层` | ✅ | 常量 + 控制消息编解码 + 数据包编解码 | -| 5 | `feat(net): feth 虚拟网卡与 BPF 链路层` | ✅ | 含私有 ABI 声明与 LoopbackLink | -| 6 | `perf(common): 无锁队列批量发布` | ✅ | 1514B 提速 2.4 倍、64B 提速 5.0 倍 | -| 7 | `feat(usb): USB 传输层` | ✅ | 设备发现/声明/控制通道/异步传输池 | -| 8 | `feat(rndis): RNDIS 状态机` + CTest 缺陷修复 | ✅ | **顺带发现测试一直在空跑,见第 7 节第 9 条** | -| 9 | `feat(core): 数据路径桥接` | ✅ | 三线程模型 + 背压 + 统计;修了 TX 统计恒为 0 的缺陷 | -| 10 | `feat(app): 运行时编排与命令行` | ✅ | 启动/停机顺序、信号处理、CLI | -| 11 | `docs: 设计文档、协议参考、性能指南` | ✅ | | -| 12 | `feat(capi): C ABI 边界与免 root 的三项能力` | ✅ | 版本/环境预检/设备枚举;日志改环形缓冲 + 轮询 | -| 13 | `refactor(core): Runtime 改为自持控制线程` | ✅ | 非阻塞 Start、一致快照、事件汇 | -| 14 | `feat(capi): 会话生命周期、状态快照与事件轮询` | ✅ | 枚举对齐用 static_assert 焊死 | -| 15 | `feat(capi): 网卡上网方式配置与孤儿网卡清理` | ✅ | DHCP / 静态 IP;feth 落盘登记 | -| 16 | `build: 输出共享库 libtetherkit` | ✅ | tk_capi 改 OBJECT 库;导出符号白名单 | -| 17 | `feat(gui): SwiftPM 工程骨架、C 互操作与 XPC 协议` | ✅ | 头文件走符号链接,永不失同步 | -| 18 | `feat(gui): 特权 helper 与授权凭据复核` | ✅ | LaunchDaemon + AuthorizationRef | -| 19 | `feat(gui): SwiftUI 设计系统与主界面` | ✅ | 状态卡/设备/吞吐/日志 | -| 20 | `feat(gui): 上网方式配置界面` | ✅ | DHCP / 静态 IP + 生效状态回读 | -| 21 | `build(gui): 打包与安装脚本、GUI 架构文档` | ✅ | | -| 22 | `feat(gui): 菜单栏实时速率与后台运行模式` | ✅ | MenuBarExtra + 仅菜单栏模式 + 自适应轮询;顺带修 helper 不清理失败态旧会话的缺陷 | -| 23 | `feat(gui): App 内一键安装与卸载特权组件` | ✅ | AEWP + helper 二进制 setuid(0);载荷内嵌 .app,dist/helper 废除;「bash 对 euid≠ruid 掉权」的坑记入 SPIKE | -| 24 | `feat(gui): 应用图标` | ✅ | 外围白底按边缘连通泛洪抠透明(内部白色元素保留);icns 全尺寸;Dock 实测核对 | -| 25 | `feat(gui): 检查更新(只查不换)` | ✅ | GitHub releases/latest + 语义化比较;每日静默 + 菜单手动;免证书分发下自动替换会被 Gatekeeper 拦死,故只查不换 | -| 26 | `feat!: 命令行更名 tetherkit-cli;App 自动维护 Finder 别名` | ✅ | OUTPUT_NAME 改产物名(`tetherkit` formula 名让位 GUI);别名用 bookmark API(软链聚焦不认),首次启动自动建立 —— **brew 连 postinstall 都在沙箱里,写不了 /Applications(实测)**;本机聚焦索引损坏(mdutil unknown state),入索效果待索引重建后复核 | -| 27 | `build(ci): GUI 构建入 CI;发版附带 .app;tap 同步双 formula` | ✅ | GUI job(macos-14/26);build-gui.sh 支持 --swift-build-flags=--disable-sandbox(SwiftPM 沙箱嵌不进 brew 沙箱) | -| 28 | `chore(release): v0.1.2 —— README 图标/截图/双 formula 安装说明` | ✅ | 中英双语;docs/assets | -| 29 | `chore(release): v0.1.3 —— 设备名不再因会话占用而丢失` | ✅ | capi 字符串记忆回填 + helper 占用判定修正(第 7 节第 17 条) | -| 30 | `feat(i18n): 全量文案外置,中英双语` | ✅ | C++ 约 390 条 + Swift 220 条;命令行 `--lang`、C ABI `tk_set_language`、GUI 语言菜单;XPC 协议号 2 → 3 | -| 31 | `chore(release): v0.1.4 —— 中英双语` | ✅ | README 英文版转正为默认(HoRNDIS 搜索意图 SEO);**升级后用户必须在 App 内更新一次特权组件**(协议号变了) | -| 32 | `feat(gui): 特权组件与 App 版本不一致时给出更新入口` | ✅ | 比的是**库**版本而不是 Info.plist(`swift run` 没有 bundle);只取版本号,构建配置不同不算不一致;协议号没变的版本以前完全没有提示 | -| 33 | `chore(release): v0.1.5 —— 组件版本对不上时可一键更新` | ✅ | 协议号仍是 3,旧组件照常能用;升级后管理行会提示更新,不更新也不会坏 | -| 34 | `feat(gui): 侧栏五页布局与 AppKit 主窗口(本分支)` | ✅ | 两栏仪表盘改为五页侧栏;主窗口改 AppKit 自管(第 7 节坑 19、20);sdk 26 门控 chrome(第 7 节坑 19);页面切换零动画(第 7 节坑 22、23,docs/GUI-ARCHITECTURE.md 4.11);无障碍按压与键盘导航(第 7 节坑 24;round 6-10 系统调研与自驱环探索,round 11 定型:移除自驱环,原生 Tab 双向贯通,窗口方向键全态切页 + Return 认领与行级后背,ButtonTurn 激活防插值) | -| 35 | `build(ci): split CI into tests, build, and lint workflows` | ✅ | ci.yml's five jobs moved into tests.yml with their steps and checks unchanged, triggers narrowed to PR + push main + manual (tag pushes covered by the self-sufficient release.yml; the swift-app branch is retired); new build.yml (workflow_dispatch only, runner-image and build-type inputs, .app and shared-library artifacts) and lint.yml (clang-format as a hard gate; clang-tidy advisory, since WarningsAsErrors: '' in .clang-tidy is an existing choice, promote it once the backlog is zero); all actions pinned to the latest major, checkout@v7 / upload-artifact@v7, with the .app uploaded via v7 single-file direct upload so the ditto zip arrives as itself | -| 36 | `build(ci): add weekly regression, ASan/UBSan, CodeQL, benchmarks, and hardening` | ✅ | tests.yml gains a Monday cron (catches runner-image / new Xcode / latest libusb / xnu ABI drift before the next PR) and an ASan+UBSan job (splits the work with TSan: memory and UB vs concurrency; Sanitizers.cmake only forbids ASan+TSan together); new codeql.yml (github/codeql-action@v4, manual C++ build analysis, push main + cron, a hard complement to advisory lint) and bench.yml (dispatch-only benchmarks, Release without sanitizers, Markdown artifact, hosted-runner noise caveat); dependabot.yml bumps actions weekly in one grouped PR (backs the latest-major convention); tests/lint/build get permissions: contents: read; Xcode selection filters out beta versions | -| 37 | `build(ci): translate release and tap workflows to English` | ✅ | release.yml and update-tap.yml translated wholesale (comments, step names, error echoes, and the user-facing Release notes heredoc); release.yml's checkout pin bumped v5 -> v7 per the latest-major convention; the two runtime strings the abi-check greps depend on (ReportSkip's root-skip message and the pair-creation MESSAGE in tests/test_net_link.cc) are now English literals, and tests.yml greps the matching strings byte-exactly (MESSAGE prints on success verified empirically against the vendored doctest 2.4.12) | -| 38 | `chore: point the tap, README links, and update checks at justsrc` | ✅ | update-tap.yml (clone url, local tap path, audit/install taps) and release.yml's Release-notes brew lines repointed to justsrc/tap; README.md and README.zh-CN.md install lines and tap link follow; UpdateChecker's releases/latest endpoint and the READMEs' Releases links switched to fork releases per user decision, so the fork becomes the update source (pre-first-release the API 404s and the checker already surfaces that as Failure.noReleases); user-side steps outstanding: create justsrc/homebrew-tap, copy both formulas from the old tap and point their homepage at justsrc/TetherKit, add the public key as a write-scoped deploy key, store the private half as HOMEBREW_TAP_DEPLOY_KEY on justsrc/TetherKit | -| 39 | `fix(ci): survive the macOS 14 image retirement and rescope the format gate` | ✅ | Diagnosed PR #2's first runs (Tests 42487700197, Lint 42487700062, 2026-11-08): all four macos-14 jobs cancelled ~60s in with zero steps and the check-run annotation "The configuration 'macos-14-arm64-us-default' is not supported" — GitHub retired the macos-14 runner image on 2026-11-02 (runner-images #13518); not fail-fast (matrices set fail-fast: false), not concurrency (no second run existed); Lint's clang-format job ran a repo-wide gate the repo could never satisfy — the baseline is unformatted under current clang-format (~65 files, 491 violation lines in tests/test_net_link.cc alone). Fixes (commit b6135fd): macos-14 -> macos-15 in the three tests.yml matrices and build.yml's dispatch choice, with honest comment updates (macos-14 runtime-floor coverage ends; the minos-14.0 link flag and the Compile-for-13.3 job carry what they can); clang-format gate rescoped from repo-wide files to changed lines via vendored LLVM clang-format-diff.py (.github/scripts, run `-style file` explicitly because recent clang-format releases, 23.1.5 included, already default to file style and the flag survives a future default change (in sync with lint.yml's corrected comment)), diff base = PR base tip / event.before / HEAD~1 with a conditional base fetch and fetch-depth 2, plus an advisory repo-wide backlog count in the step summary; verified locally: positive probe (injected 200-col literal) exits 1, clean run exits 0, the branch's own hunks pass, and --dry-run without -Werror exits 0 so the summary always lands. A whole-file reformat of test_net_link.cc was tried and reverted: it moved 63 upstream Chinese lines (test titles/comments) into the diff for zero functional gain | -| 40 | `feat(ci): add xcode-27 image legs to the Tests matrices` | ✅ | Xcode 27 is not an /Applications entry on the macos-26 image — under GitHub's one-major-Xcode-per-image model it ships as the separate label `xcode-27` (macOS 27 base, default toolchain Xcode 27.x; preview 2026-07-16, GA 2026-10-15, runner-images #14404; the macos-26 image tops out at 26.6 and macos-15 at 26.3, verified against both image Readmes); build-test, gui, and abi-check matrices therefore gain `xcode-27` as a third `os` value — `runs-on: ${{ matrix.os }}` carries the label directly, no select step needed, names render as `xcode-27 · Release` / `GUI · xcode-27`; gui's select-newest step picked 27.x non-beta automatically at the time (the next commit replaces discovery on xcode-27 legs with a deliberate Xcode 27.1 pin: the image also ships Xcode 27.2.0 as a name-clean release-candidate symlink); abi-check gains it on purpose: the macOS 27 base means a new kernel/xnu, the exact surface where the FethPair private ABI could drift (the job's own long-term concern); tsan/asan-ubsan/deployment-target stay on macos-26 defaults with a pointed comment; new legs cost +4 macOS jobs per Tests run (11 -> 15), deliberately riding every PR (10x-billed macOS minutes accepted as the price of review-time drift catching); matrix entries documented with label provenance | -| 41 | `fix(ci): make UBSan fatal and run the feth cases under it` | ✅ | Round-two review, B3. UBSan was report-and-continue: a diagnostic printed, the process kept going, ctest exited 0 through real undefined behavior. cmake/Sanitizers.cmake now appends -fno-sanitize-recover=undefined when TETHERKIT_ENABLE_UBSAN is set; the variable stays empty otherwise, so ASan-only and TSan builds get no extra flag (verified: a full configure with ASan+UBSan on writes -fno-sanitize-recover=undefined into compile_commands.json and flags.make, and c++ compiles a prober with the flag). The asan-ubsan Test step passes UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 for sanitizer runtime pieces the compiler cannot touch, and runs the feth/BPF cases as root after net.link.fake.hwcsum=0; skipped root cases read green while the BPF buffer walk (bh_hdrlen, BPF_WORDALIGN) and the reinterpret_cast-heavy if_fake bridge never reach the sanitizer | -| 42 | `fix(ci): pin Xcode 27.1 on xcode-27 legs; filter RCs elsewhere` | ✅ | Round-two review, M1. The xcode-27 image ships Xcode_27.2.0 as a release-candidate symlink whose clean name no filter can call out: live run 42491763714 (on e06208a) picked it through a beta-only filter and the leg went green on a pre-release toolchain. build-test and gui now run a Pin step on xcode-27 legs: test -d /Applications/Xcode_27.1.app or error out asking for a deliberate bump, then xcode-select it. macos-15 and macos-26 keep discovery but the filter widens to release candidates (grep -ivE 'beta|release.?candidate'); build.yml's dispatch os choice gains xcode-27 with the same select/pin split. abi-check needs no pin: it never selects an Xcode, so its xcode-27 leg rides the image default | -| 43 | `fix(ci): stop tee and diff from masking the exit status` | ✅ | Round-two review, B2. GitHub runs shell steps as bash -e with no pipefail, so a pipeline's exit code is its last command's. The abi-check forensic job read tee's always-zero status, so a ctest failure after the pair-creation MESSAGE could hide behind passing evidence; the lint format gate would feed empty stdin to clang-format-diff.py when git diff failed and read the checker's 0 as a formatting pass. Both steps set -o pipefail first, with a comment on each site saying why; lint's base-resolution step already fails the job under -e, the flag closes the same hole independently | -| 44 | `fix(ci): pass inputs by env; preflight the tap; cap the bench` | ✅ | Round-two review, B1 plus related minors. Inputs and step outputs never interpolate into run: text anymore: bench_args travels as BENCH_ARGS, build.yml reads its dispatch os/build_type through job env DISPATCHED_OS and DISPATCHED_BUILD_TYPE (the upload with: blocks switch to env context so artifact names stay in lockstep with what the shell builds), and update-tap.yml routes inputs.tag and the src step outputs through TAP_TAG/TAP_VERSION/FORMULA_URL/FORMULA_SHA256 in its four shell steps. update-tap gains a Preflight step verifying both formulas exist in justsrc/homebrew-tap before any perl rewrite starts (a missing formula previously died mid-loop with a perl message); release.yml drops secrets: inherit for the explicit HOMEBREW_TAP_DEPLOY_KEY map, keeping every other caller secret out of the callee's scope; bench.yml gets permissions: contents: read and timeout-minutes: 30 | -| 45 | `fix(ci): scope concurrency by event; sync stale comments` | ✅ | Round-two review minors, plus the ledger corrections. The concurrency group gains github.event_name: a push to main can no longer cancel the in-flight Monday cron, the run that watches runner-image, libusb, and xnu drift. Comments now match what runs: tests.yml's header names four deviations from the ancestor ci.yml (checkout pin, RC filtering, image labels, English abi-check strings) and its trigger rationale says what tag pushes cover and what they skip; the "All 168 cases" claim stopped hard-coding the suite size after the count measured 210; the cron comment documents GitHub's 60-day auto-disable of silent schedules; release.yml's header no longer claims full self-sufficiency and names what a tag run covers and leaves out; AGENTS rows 39 and 40 now reference the landed commits b6135fd and e06208a | -| 46 | `fix(ci): block-scalar the asan-ubsan test command` | ✅ | Found by the fourth-round ASan+UBSan run: exit 127 on the Test step before a single test ran, with the error reading an env: ctest: No such file or directory whose leading space is the tell. The step had been authored as a folded plain scalar (run: with no |, three physical lines joined by trailing backslashes), and YAML folds plain-scalar newlines into spaces, so the script became one line where each backslash escapes the following space and glues neighboring words into single tokens; ctest reached sudo env as a command named " ctest". Shell-style line continuations are only meaningful inside a block scalar, which every other continuation-bearing step used already, abi-check included. The step now writes the three lines as a run: | block mirroring abi-check; verified YAML parses and no other workflow carries a plain-scalar continuation (scan vacuous). The build with -fno-sanitize-recover=undefined succeeded in the same run, the failure was purely this step's syntax | -| 47 | `fix(ci): keep permission cases non-root in asan-ubsan` | ✅ | Fifth round, run 42504799177 (on 3688329): the asan-ubsan job ran the whole suite as root and the capi permission cases failed on their contract assertions (tests/test_capi.cc:402 and 494): tk_session_start returned 0 and tk_net_apply/tk_net_clear returned -3 where the fixtures assert the documented non-root behavior, TK_ERR_PERMISSION (-4); as root those gates legitimately pass, and tk_cleanup_orphan_interfaces outright succeeded. Copilot's review suggestion to make the tests expect the executing user was rejected: the non-root contract is product behavior (it is what the CLI turns into the please-run-as-root hint), and teaching the CHECKs to accept root would make a future regression to these gates read green. The Test step split in two: the suite as the runner user with UBSAN_OPTIONS as step env (permission cases hold, feth/BPF root cases skip quietly), then a filtered sudo run, ctest -V -R 'net\.' 2>&1 with tee and pipefail, carrying abi-check's guard pair verbatim (skip-marker grep and the created-feth confirmation; strings verified against tests/test_net_link.cc's ReportSkip line 83 and the pair-creation MESSAGE line 435), so a silent skip cannot read green and a genuine sanitizer finding in the BPF buffer walk or the private-ABI bridge fails the step loudly | -| 48 | `fix(ci): declare the tap deploy key on the callee` | ✅ | Third-round review, N1, referee-verified. release.yml passes the reusable update-tap.yml a named secrets map, which declared only inputs.tag, yet its Set-up-the-deploy-key step expands secrets.HOMEBREW_TAP_DEPLOY_KEY: GitHub validates a named map against on.workflow_call.secrets and an unmatched name fails the run at startup with zero jobs and no step logs (annotation on the caller: Invalid secret, HOMEBREW_TAP_DEPLOY_KEY is not defined in the referenced workflow; Stack Overflow 69493352/76799186); the old secrets: inherit skips that validation entirely, which is why the one-way narrowing broke it, and PR CI cannot see this because workflow_dispatch faces no such declaration: the first real tag push is exactly where it dies. workflow_call now declares HOMEBREW_TAP_DEPLOY_KEY: required: true (dispatch declares no secrets; a workflow_call declaration is where the caller's map is checked) | -| 49 | `fix(ci): scope update-tap's token; key travels by env` | ✅ | Third-round residuals for update-tap.yml, the one workflow left without permissions or a timeout: it now sets workflow-level permissions: contents: read (the tap push rides the scoped deploy key, so its GITHUB_TOKEN needs no write access anywhere in this repo) and job-level timeout-minutes: 30, matching bench.yml's cap. The Set-up-the-deploy-key step no longer expands the secret raw inside run: text: it travels as step env DEPLOY_KEY and the printf reads "$DEPLOY_KEY", the same env-passage doctrine the src step's comment already stated | -| 50 | `fix(ci): scope lint's concurrency group by event` | ✅ | Third-round residual: lint.yml's concurrency group was workflow-ref with no event_name, so a push to main and a manual dispatch on the same ref could cancel each other's in-flight lint; tests.yml had gained event_name in f0fbc1d and lint was the holdout. The group now appends github.event_name exactly as tests.yml writes it, with the two-event rationale folded into the concurrency comment | -| 53 | `docs: correct row 50's event_name provenance to tests.yml` | ✅ | Unanimous round-three reviewer finding, applied verbatim after re-verification at the source: row 50 credited tests/build with gaining event_name in f0fbc1d, but that commit touched release.yml and tests.yml only, and build.yml has never carried a concurrency block at all (pickaxe for event_name and for concurrency: both empty; it is dispatch-only with nothing to cancel). Row 50 now attributes the earlier scoping to tests.yml alone; the lint.yml workflow change itself was always correct and needed no second look. Non-blocking doc-only fix | -| 52 | `fix(ci): sync the root-leg and style-default comments` | ✅ | Third-round comment syncs. tests.yml's sanitized root step claimed the net\. filter selects the root-gated feth/BPF cases only, but ctest picks up all three net.* suites (live run 42506526442 executed net.abi, net.loopback, and net.feth there): the comment now says exactly that, net.feth is the one root gates and the other two ride along under the sanitizer build. AGENTS row 39 said the format gate runs `-style file` "since the binary default is LLVM style", contradicting lint.yml's corrected comment (recent clang-format already defaults to file style; the flag survives a future default change): row 39 now carries the corrected rationale. Delegation record: three antigravity/gemini-3.8-flash delegates applied the lint, bench, and tests edits byte-exact within scope; the update-tap edits were applied directly, because the workspace guard blocks non-primary providers from a secret-bearing file, and update-tap.yml is exactly such a file | -| 51 | `fix(ci): drop the dead bench args dispatch input` | ✅ | Third-round residual, addressed rather than left moot: the Run step carried the args dispatch input through an unquoted BENCH_ARGS (intentional word-splitting), but the input itself was dead: benchmarks/ contains no argv or argc use at all (grep-verified before the comment that says so was written), so no dispatch text could ever change the run. workflow_dispatch now declares no inputs, the Run step invokes tetherkit_bench bare behind pipefail+tee, and the inputs.args env-passage comment went with the plumbing it described | -| 54 | `fix(ci): force arm64 target architecture in codeql build` | ✅ | Diagnosed CodeQL run 42513807606 (2026-11-08): job failed with linker error "Undefined symbols for architecture x86_64" and "found architecture 'arm64', required architecture 'x86_64'" against Homebrew's /opt/homebrew/Cellar/libusb/1.0.30/lib/libusb-1.0.dylib. CodeQL's macOS tracer forces traced child processes onto the x86_64 slice under Rosetta; without an explicit target architecture, AppleClang emits x86_64 objects that cannot link against arm64-only Homebrew dependencies. `codeql.yml` pins `CMAKE_OSX_ARCHITECTURES=arm64` so clang cross-emits arm64 regardless of driver process architecture; `arch -arm64` cannot be used instead because child builds forced native bypass CodeQL tracing. Hardened `codeql.yml` with a `pull_request` trigger scoped to `.github/**` so workflow edits self-verify. Verified green by two follow-up runs on the branch — 42578919426 (pull_request event) and 42578945162 (workflow_dispatch; 11m59s C++-analysis job span, 12m5s run wall, Build and analyze clean against arm64 Homebrew libusb) — sealing the failure-plus-verification pairing exactly like rows 39/41/42/47. | -| 55 | `fix(ci): narrow codeql pr trigger, event-scope concurrency` | ✅ | Review round on row 54, five findings, four applied in codeql.yml plus this row's amendment: (1, consensus) pull_request paths narrowed `.github/**` -> `.github/workflows/**` - dependabot.yml and .github/scripts/ edits never reach the analyzer, while Dependabot's weekly grouped action-bump PR still matches by construction (it edits workflow files) and the header comment now states that ride-along as deliberate; (2, sharpest catch) the header's "No concurrency block: canceling would throw away the whole analysis" sentence predated the PR trigger and no longer held - a superseded PR commit's SARIF is simply replaced by the newer run - so an event-scoped concurrency group mirroring tests.yml landed (group `${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}`, cancel-in-progress: true) with a comment explaining why cancellation is safe within one event and why event_name in the key keeps a push to main from canceling the Monday cron; (3) row 54 amended (above) to cite verification runs 42578919426 and 42578945162, sealing the failure-plus-verification pairing the ledger's audit value depends on; (4, nit) the Build step comment now carries the hard-coded-on-purpose guard - under the tracer `uname -m` reports x86_64, so a `-DCMAKE_OSX_ARCHITECTURES=$(uname -m)` "simplification" would silently reintroduce the bug (the deliberate-choices-need-a-magnitude rule from pitfall 16); (5, INFO) the first-ever green analysis surfaced one pre-existing `cpp/use-after-free` at vendored third_party/doctest/doctest.h:3710 - resolved as a documented decline rather than config: `paths-ignore` is verifiably inert for C/C++ manual builds (codeql team statement in github/codeql-action#3890; GitHub docs confine paths filtering to build-mode none; the traced run would warn "path-based filtering not supported"), and SARIF post-filtering via advanced-security/filter-sarif means a new third-party action for exactly one benign vendored alert - the workflow header records the accepted baseline instead; the fork-PR-403 finding was retracted by the reviewers against current GitHub docs (SARIF upload on pull_request events is permitted under read-only fork tokens). Two disjoint antigravity/gemini-3.8-flash delegates applied the codeql.yml and AGENTS.md edits byte-exact within disjoint file scopes. Verification: the PR-triggered analysis run 42583242299 is green on 75634ad, and the new group's cancel-in-progress proved itself on this very push - the in-flight run 42582860934 for the superseded commit 84d8151 was canceled and replaced by the 75634ad run. | -| 56 | `feat(i18n)!: drop the Chinese column, keep the single-language wiring` (this branch) | ✅ | Maintainer decision: English-only, but preserving the multi-language plumbing so adding a language stays a bounded five-touch change (checklist now lives at the top of messages.def). messages.def went 843 -> 529 lines via a string-aware perl splice (TETHERKIT_MESSAGE(id, zh, en) -> (id, en)), 310 records; guards on kept column: no Han, no full-width punctuation. i18n.h/i18n.cc rewritten in English: Language enum reduced to kEnglish=0, single kMessageTable, TextIn kept as the designed per-language dispatch point, ParseLanguage no longer accepts zh/Chinese (returns false, CLI rejects with new English message), LanguageFromLocaleString parked at "always English" with the prefix rule to return there, ContextSeparator fixed to ": ". C ABI signature-stable: TK_LANGUAGE_CHINESE removed; ToLanguage returns nullopt for unknown values so a caller still sending the old Chinese value 1 is ignored and stays in English; helper XPC setLanguage method and protocolRevision 3 untouched (still applied, inert). Four ScopedLanguage guard sites flipped kChinese->kEnglish with English expectations (bridge stats "queue depth", CONDIS reject "connection-oriented", keepalive fatal "keep-alive", MalformedReason "none"); test_net_link's sysctl-why bilingual loop collapsed to the single table. test_common_i18n.cc rewritten: per-string placeholder well-formedness, no mixed indexing, contiguous indices 0..n-1; the cross-language parity checks were retired and are documented to come back when a second language lands. CLI help no longer advertises zh. Verification: cmake -Werror build clean, ctest 23/23 suites pass, smokes green (--version; --help shows the reworded --lang lines; --lang zh --list errors in English; --lang en --list and LC_ALL=zh_CN --list render English). GUI side deliberately untouched this phase, so `swift test` still exercises bilingual Swift tables and must be green throughout; the Swift strip is the next commit. Splice/validation scripts live in the session temp dir (not in the repo). Untracked docs/icon_resources/ and draft_icons/ predate this work and were left alone. | -| 57 | `feat(i18n)!: strip the Swift side to English-only, keep the add-a-language wiring` (this branch) | ✅ | Phase 2 complement to row 56. LocalizedStrings.swift went 743 -> 685 lines via a string-aware perl splice: every `localizations` switch row lost its Chinese first argument, with two guards - the dropped arg must contain Han or full-width punctuation OR be identical to the kept one (the languageEnglish native-name row needs the second rule), and the kept column must be Han- and fullwidth-free; kept spans are preserved byte-exact, including multi-line `+`-concatenation layouts (installer/update/permission strings); the file header and 42 MARK/comment lines went to English via an exact-line map; the `languageChinese` key is gone entirely (both Pickers hard-code `Text(verbatim: "English")` under the tag, so the key had no non-test consumers). Language enum is `.english` only with `cValue` constant 0; LanguagePreference loses `.chinese`, so a stale saved rawValue "chinese" decodes to nil and falls back to `.system` (graceful, symmetric with the C ABI ignoring unknown values); `L10n.apply` and `text(_:in:)` survive as the add-a-language dispatch points; new internal `L10n.localeLanguage` is parked always-English, exactly mirroring LanguageFromLocaleString on the C++ side, and `systemLanguage` still reads Locale.preferredLanguages through it; HelperService.setLanguage keeps its ignore-unknown semantics (an old App still sending "chinese" leaves the helper in English); both language menus now offer Follow system + English. **Swift constraint determined empirically** (tiny probe program): single-element labeled tuples are illegal - `cannot create a single-element tuple with an element label` - so the planned `(english: String)` one-member tuple is unrepresentable and rows are bare strings; when a language is added the property becomes a real labeled tuple and every case appends one labeled argument, the same way C++ rows `(id, en)` grow. LocalizationTests rewritten for the single table, still 7 cases: per-key placeholder contiguity, the %ld-integer rule, language resolution incl. localeLanguage, and formatting substitution; the cross-language placeholder/parity checks retire until a second language and the suite header says they return then. Verification: swift build green (3.3s, only the pre-existing libtetherkit dylib-version warning); swift test 30/30 - TetherKitIPCTests 17/17 (Authorization 2 + HelperConstants 8 + the 7 rewritten LocalizationTests) and TetherKitUIModelTests 13/13; the current-state snapshot below says "17 IPC + 7 UIModel = 24", which predates the sidebar test additions, not this work. Also folded in here: the row-56 typo fix (i18n.cci -> i18n.h). Splice/unwrap scripts live in the session temp dir. | -| 58 | `feat(i18n)!: translate every Chinese comment and user-facing benchmark string to English` (this branch) | ✅ | P3 = the comment-and-doc-string half of the English-only migration (P1 = C++ message table, P2 = Swift string table). Scope: ~100 files, every Han comment in `include/`, `src/`, `tests/`, `benchmarks/`, `gui/` plus the benchmark tool's own report output; `docs/` + `README` + `AGENTS.md` are P4. Work was delegated in disjoint batches through the T3 `delegate_task` tool on the three OpenCode models the maintainer named (iceman-free, minimax-m3.1-flash-free, mimo-v2.6-flash-free; MiMo hit a free-tier limit mid-run and its remainder was re-dispatched to MiniMax); every batch was verified before acceptance, and three were sent back or fixed by hand. Verification method, all in-session: a comment-only scope proof (comments stripped from HEAD and from the tree, then the code token streams compared -- byte-identical for every file except the deliberately allowed literals), a comment-line-count drop check (a translation must not shrink a file's prose), a repo-wide Han census, the CI lint gate reproduced locally (vendored `.github/scripts/clang-format-diff.py` over the changed hunks, base HEAD), a full `-Werror` build plus 23/23 ctest, and `swift build`/`swift test` for the GUI batches. Findings worth keeping: (1) the house style is **pure ASCII in comments** -- the earlier crew had already converted box-drawing to `+ - |` in `feth_device.h` and dropped the `star`/`warning` markers, so two batches that kept them were normalized afterwards (emphasis now lives in the prose); (2) the CI format gate is **line-scoped**, so touching a test title drags its whole `TEST_SUITE` block into scope and clang-format then wants it re-indented by 2 -- the Chinese-era test files had their cases at column 0 and were never format-clean; the reformat was applied with an invariant that the **code token stream** is unchanged (a re-indent or a re-wrap may move a line break, never a token) and that the **comment word stream** is unchanged (a long comment may be reflowed, never reworded); (3) `src/app/main.cc` had to have its include blocks reordered (project headers before system headers, per `.clang-format`) -- pre-existing baseline debt that only became visible once a comment above it changed; (4) `docs/BENCHMARKS.md` is **half generated**: its first part is the literal output of `tetherkit_bench`, so the tool's report strings had to be translated too (they are user-facing) and the document's generated part must use exactly the wording the tool now prints -- but the document must NOT be regenerated, because that would drop the root-measured feth/BPF numbers and the hand-written analysis; (5) the `device` fixture in `tests/test_capi.cc` was **swapped, not translated**: it is a byte-pattern fixture (two 3-byte UTF-8 characters) and became two U+20AC characters (the euro sign, 3 bytes each, 6 bytes total) with every byte count in the assertions preserved; (6) the two CI grep anchors in `tests/test_net_link.cc` (the `ReportSkip` format string and the `created {} <-> {}` message) are byte-exact and must stay so; (7) a *passing* test is not proof -- a log-text needle such as `find("libusb initialized")` must match the production message table exactly or the assertion passes vacuously; (8) the Chinese strings in `tests/test_common_error.cc` are **paired** (a context string and the assertion on it) and were translated on both sides with identical wording, which is why the brief for that batch had to be corrected before dispatch. The GUI language plumbing, the XPC protocol revision 3 and the C ABI are untouched by this phase | - -### 当前状态 - -- **测试**:23 个 ctest 用例全部通过(新增 common.i18n 核对两种语言的占位符); - GUI 侧 `swift test` 24 个用例通过(17 个 TetherKitIPC + 7 个 - TetherKitUIModel,含 LocalizationTests、HelperConstantsTests) -- **构建**:`-Werror` 零告警;命令行、共享库、GUI 三套产物均可构建 -- **CI**: legacy ci.yml split into tests.yml (five jobs migrated with their - steps and checks unchanged, triggers PR + push main + Monday cron + ASan/UBSan), - build.yml (manual trigger only, full build + artifacts) and lint.yml - (clang-format gate + advisory clang-tidy); new codeql.yml (weekly + push main - C++ scanning), bench.yml (manual benchmarks) and dependabot.yml (weekly - grouped action bumps); permissions: contents: read across tests/lint/build; - Xcode selection filters beta; every action pinned to the latest major - (checkout@v7, upload-artifact@v7) -- **可运行**:`--version` / `--help` / `--list` 均正常;非 root 启动给出清晰提示 - 并返回退出码 1;`TetherKit.app` 与 `tetherkit-helper` 打包后均可启动; - 特权组件可在 App 内一键安装 / 卸载(安装路径已真机走通); - 组件版本与 App 对不上时管理行点亮「更新特权组件」(`brew upgrade` 只换 .app, - 装在 /Library 的那份不会跟着变 —— 协议号没变时以前完全没有提示, - 用户唯一能察觉的迹象是「更新说明里写着修好的毛病还在」;两种语言各截窗口核对过); - 中英双语已实测:命令行两种语言的 `--help` / `--list` / 参数错误均正确, - GUI 在菜单里切换语言后主窗口与 App 菜单**立即**变(不需重启,截窗口核对过) -- **已验证**:USB 侧在真实 RNDIS 设备上跑通(枚举、声明接口、RNDIS 握手), - 且 USB 这一侧**不需要 root**;feth 私有 ABI、两个私有 BPF ioctl、 - 以及「BPF 写入能让对侧 IP 栈收到帧」这个核心前提都已实测确认。详见第 6 节 -- **已压测**(2026-07-28,Android 手机 USB 网络共享):端到端 iperf3 实测 - **RX 324~327 Mbps / TX 234~241 Mbps**(USB 2.0 高速,理论上限 426 Mbps); - GUI 与真实设备的完整链路走通(插上 → 连接 → DHCP 拿到地址 → 双向 ping)。 - 详见第 6.4 节与 [docs/BENCHMARKS.md](docs/BENCHMARKS.md) -- **已修复**:TX 高负载丢帧的根因已查明 —— 桥接层在 bulk OUT 传输池占满时立即 - 丢弃批次剩余帧,而槽位只需等几百微秒。改为等待后 TX 丢帧 1889 → 0、 - TCP TX 重传 3829 → 0、双向并发 TX 4.8 → 87 Mbps;Ctrl-C 优雅停机在修复后 - 也复核过(打出「已停机」、feth 无残留)。见第 6.5 节 -- **已修复**(2026-07-28):连接后 GUI 设备列表把「vivo iQOO Z10x + 序列号」 - 退化成「USB 设备 2d95:600b」并挂满整个会话 —— 启动竞态窗口里的一次枚举 - 读不到字符串描述符,空串覆盖了好数据。修法是回填而不是堵窗口:capi 记住 - 每台设备上次成功读到的字符串,读不到就回填;helper 的占用判定同步修正 - (握手窗口算占用、failed/stopped 死会话不算)。真机复核过(read_strings - =false 时名字完整)。见第 7 节第 17 条 -- **仍未查清**:TCP TX 呈双峰(约 240 / 约 300 Mbps,两态都零重传); - 双向并发时 RTT 仍从 0.4 ms 膨胀到 16.6 ms(已不再造成吞吐塌陷) - -### GUI 相关 - -**改 GUI、C ABI 或 Runtime 线程模型之前,先读 `docs/GUI-ARCHITECTURE.md`。** -那里记着一批「写错了不会当场报错、但会以很难查的方式出问题」的约束:UTF-8 -边界截断、枚举顺序耦合、`cp -a` 与软链、install_name_tool 后必须重签、 -授权复核的三个标志位、pipe 死锁的读写顺序等。 - -## 6. 验证清单(需要 root 或真实 RNDIS 设备) - -### 6.1 已验证:USB 侧(**无需 root**) - -已在一台真实 RNDIS 设备上实测。结论按平台事实记录 —— 它们是 macOS/libusb 的行为, -不依赖具体设备型号。 - -**① 非 root 下 `libusb_open` 不会返回 `LIBUSB_ERROR_ACCESS`,而是直接成功。** - -原先的假设是错的。macOS 打开 USB 设备**不需要 root**(不同于 Linux 的 udev 权限 -模型)。整个 USB 侧 —— 枚举、`libusb_open`、声明接口、收发传输 —— 都能以普通用户 -运行;root **只**为创建 feth 与打开 `/dev/bpf*` 而需要。`--list` 因此明确标注了 -「不需要 root」。 - -**② ⚠️ `libusb_kernel_driver_active() == 1` 在 macOS 上并不预示 `claim` 会失败。** - -这是本节最容易踩错的一条。同一台复合设备上实测: - -| 接口签名 | 匹配到的驱动 | `kernel_driver_active` | `libusb_claim_interface` | +| 1 | `chore: initialize project scaffolding and build system` | Done | CMake + convention config + vendored doctest + version injection | +| 2 | `feat(common): infrastructure layer` | Done | Errors/logging/byte order/lock-free queue/statistics/thread QoS | +| 3 | `feat(bench): benchmark harness and microbenchmarks` | Done | In-tree harness, Markdown output written directly into docs/ | +| 4 | `feat(rndis): RNDIS protocol layer` | Done | Constants + control-message encode/decode + data-packet encode/decode | +| 5 | `feat(net): feth virtual interface and BPF link layer` | Done | Includes private ABI declarations and LoopbackLink | +| 6 | `perf(common): lock-free queue batch publish` | Done | 2.4x speedup at 1514B, 5.0x at 64B | +| 7 | `feat(usb): USB transport layer` | Done | Device discovery/claim/control channel/async transfer pool | +| 8 | `feat(rndis): RNDIS state machine` + CTest defect fix | Done | **Also discovered that tests had been running empty all along; see pitfall 9 in section 7** | +| 9 | `feat(core): data-path bridging` | Done | Three-thread model + backpressure + statistics; fixed the defect where TX statistics were always 0 | +| 10 | `feat(app): runtime orchestration and CLI` | Done | Startup/shutdown ordering, signal handling, CLI | +| 11 | `docs: design document, protocol reference, performance guide` | Done | | +| 12 | `feat(capi): C ABI boundary and three capabilities that do not require root` | Done | Version/environment preflight/device enumeration; logging switched to a ring buffer + polling | +| 13 | `refactor(core): Runtime changed to a self-owned control thread` | Done | Non-blocking Start, consistent snapshots, event drain | +| 14 | `feat(capi): session lifecycle, status snapshots, and event polling` | Done | Enum alignment pinned down with static_assert | +| 15 | `feat(capi): interface network-access configuration and orphan interface cleanup` | Done | DHCP / static IP; persistent feth registration | +| 16 | `build: output shared library libtetherkit` | Done | tk_capi changed to an OBJECT library; export symbol allowlist | +| 17 | `feat(gui): SwiftPM project skeleton, C interop, and XPC protocol` | Done | Headers use symlinks so they never fall out of sync | +| 18 | `feat(gui): privileged helper and authorization credential recheck` | Done | LaunchDaemon + AuthorizationRef | +| 19 | `feat(gui): SwiftUI design system and main interface` | Done | Status card/device/throughput/logs | +| 20 | `feat(gui): network-access configuration UI` | Done | DHCP / static IP + live status readback | +| 21 | `build(gui): packaging and install scripts, GUI architecture document` | Done | | +| 22 | `feat(gui): menu-bar live rate and background mode` | Done | MenuBarExtra + menu-bar-only mode + adaptive polling; also fixed the helper not cleaning up a failed old session | +| 23 | `feat(gui): one-click install and uninstall of the privileged helper inside the App` | Done | AEWP + helper binary setuid(0); payload embedded in .app, dist/helper removed; the "bash drops privileges when euid != ruid" pitfall recorded in SPIKE | +| 24 | `feat(gui): app icon` | Done | Flood-fill transparency from the outer white background by edge connectivity (inner white elements preserved); icns at all sizes; verified in the Dock on real hardware | +| 25 | `feat(gui): check for updates (check only, no replacement)` | Done | GitHub releases/latest + semantic comparison; daily silent + manual from menu; automatic replacement is blocked by Gatekeeper for unsigned distribution, so check-only | +| 26 | `feat!: rename CLI to tetherkit-cli; App automatically maintains a Finder alias` | Done | OUTPUT_NAME changes the product name (the `tetherkit` formula name ceded to the GUI); alias uses the bookmark API (symlinks are not recognized by Spotlight), created automatically on first launch -- **brew's postinstall runs in a sandbox and cannot write to /Applications (measured on real hardware)**; local Spotlight index was damaged (mdutil unknown state), indexing effect to be rechecked after the index is rebuilt | +| 27 | `build(ci): add GUI build to CI; attach .app to releases; keep tap formulas in sync` | Done | GUI job (macos-14/26); build-gui.sh supports --swift-build-flags=--disable-sandbox (SwiftPM sandbox cannot nest inside brew's sandbox) | +| 28 | `chore(release): v0.1.2 -- README icon/screenshots/dual-formula install instructions` | Done | At that point the project shipped Chinese and English; docs/assets | +| 29 | `chore(release): v0.1.3 -- device name no longer lost due to session occupancy` | Done | capi string-memory backfill + helper occupancy check fix (pitfall 17 in section 7) | +| 30 | `feat(i18n): externalize all user-facing strings, Chinese and English` | Done | ~390 C++ strings + 220 Swift strings at that point; CLI `--lang`, C ABI `tk_set_language`, GUI language menu; XPC protocol revision 2 -> 3 | +| 31 | `chore(release): v0.1.4 -- Chinese and English` | Done | At that point the project shipped Chinese and English; the English README became the default (HoRNDIS search-intent SEO); **after upgrading, users had to update the privileged helper once inside the App** (protocol revision changed) | +| 32 | `feat(gui): show an update entry when the privileged helper version does not match the App` | Done | Compares the **library** version, not Info.plist (`swift run` has no bundle); different build configurations do not count as a mismatch; when the protocol revision did not change there was previously no prompt at all | +| 33 | `chore(release): v0.1.5 -- one-click update when component versions do not match` | Done | Protocol revision still 3, the old helper continues to work; after upgrading the management row prompts for an update, but not updating does not break anything | +| 34 | `feat(gui): five-page sidebar layout and AppKit main window (this branch)` | Done | Two-column dashboard changed to a five-page sidebar; main window changed to AppKit-managed (pitfalls 19 and 20 in section 7); sdk 26 gates chrome (pitfall 19 in section 7); zero-animation page switches (pitfalls 22 and 23 in section 7, docs/GUI-ARCHITECTURE.md 4.11); accessibility press and keyboard navigation (pitfall 24 in section 7; rounds 6-10 system investigation and self-driven-loop exploration, round 11 finalized: self-driven loop removed, native Tab bidirectional traversal, window arrow keys switch pages in all states + Return claim and row-level backstop, ButtonTurn activation without interpolation) | +| 35 | `build(ci): split CI into tests, build, and lint workflows` | Done | ci.yml's five jobs moved into tests.yml with their steps and checks unchanged, triggers narrowed to PR + push main + manual (tag pushes covered by the self-sufficient release.yml; the swift-app branch is retired); new build.yml (workflow_dispatch only, runner-image and build-type inputs, .app and shared-library artifacts) and lint.yml (clang-format as a hard gate; clang-tidy advisory, since WarningsAsErrors: '' in .clang-tidy is an existing choice, promote it once the backlog is zero); all actions pinned to the latest major, checkout@v7 / upload-artifact@v7, with the .app uploaded via v7 single-file direct upload so the ditto zip arrives as itself | +| 36 | `build(ci): add weekly regression, ASan/UBSan, CodeQL, benchmarks, and hardening` | Done | tests.yml gains a Monday cron (catches runner-image / new Xcode / latest libusb / xnu ABI drift before the next PR) and an ASan+UBSan job (splits the work with TSan: memory and UB vs concurrency; Sanitizers.cmake only forbids ASan+TSan together); new codeql.yml (github/codeql-action@v4, manual C++ build analysis, push main + cron, a hard complement to advisory lint) and bench.yml (dispatch-only benchmarks, Release without sanitizers, Markdown artifact, hosted-runner noise caveat); dependabot.yml bumps actions weekly in one grouped PR (backs the latest-major convention); tests/lint/build get permissions: contents: read; Xcode selection filters out beta versions | +| 37 | `build(ci): translate release and tap workflows to English` | Done | release.yml and update-tap.yml translated wholesale (comments, step names, error echoes, and the user-facing Release notes heredoc); release.yml's checkout pin bumped v5 -> v7 per the latest-major convention; the two runtime strings the abi-check greps depend on (ReportSkip's root-skip message and the pair-creation MESSAGE in tests/test_net_link.cc) are now English literals, and tests.yml greps the matching strings byte-exactly (MESSAGE prints on success verified empirically against the vendored doctest 2.4.12) | +| 38 | `chore: point the tap, README links, and update checks at justsrc` | Done | update-tap.yml (clone url, local tap path, audit/install taps) and release.yml's Release-notes brew lines repointed to justsrc/tap; README.md and README.zh-CN.md install lines and tap link follow at that point; UpdateChecker's releases/latest endpoint and the READMEs' Releases links switched to fork releases per user decision, so the fork becomes the update source (pre-first-release the API 404s and the checker already surfaces that as Failure.noReleases); user-side steps outstanding: create justsrc/homebrew-tap, copy both formulas from the old tap and point their homepage at justsrc/TetherKit, add the public key as a write-scoped deploy key, store the private half as HOMEBREW_TAP_DEPLOY_KEY on justsrc/TetherKit | +| 39 | `fix(ci): survive the macOS 14 image retirement and rescope the format gate` | Done | Diagnosed PR #2's first runs (Tests 42487700197, Lint 42487700062, 2026-11-08): all four macos-14 jobs cancelled ~60s in with zero steps and the check-run annotation "The configuration 'macos-14-arm64-us-default' is not supported" -- GitHub retired the macos-14 runner image on 2026-11-02 (runner-images #13518); not fail-fast (matrices set fail-fast: false), not concurrency (no second run existed); Lint's clang-format job ran a repo-wide gate the repo could never satisfy -- the baseline is unformatted under current clang-format (~65 files, 491 violation lines in tests/test_net_link.cc alone). Fixes (commit b6135fd): macos-14 -> macos-15 in the three tests.yml matrices and build.yml's dispatch choice, with honest comment updates (macos-14 runtime-floor coverage ends; the minos-14.0 link flag and the Compile-for-13.3 job carry what they can); clang-format gate rescoped from repo-wide files to changed lines via vendored LLVM clang-format-diff.py (.github/scripts, run `-style file` explicitly because recent clang-format releases, 23.1.5 included, already default to file style and the flag survives a future default change (in sync with lint.yml's corrected comment)), diff base = PR base tip / event.before / HEAD~1 with a conditional base fetch and fetch-depth 2, plus an advisory repo-wide backlog count in the step summary; verified locally: positive probe (injected 200-col literal) exits 1, clean run exits 0, the branch's own hunks pass, and --dry-run without -Werror exits 0 so the summary always lands. A whole-file reformat of test_net_link.cc was tried and reverted: it moved 63 upstream Chinese lines (test titles/comments) into the diff for zero functional gain at the time | +| 40 | `feat(ci): add xcode-27 image legs to the Tests matrices` | Done | Xcode 27 is not an /Applications entry on the macos-26 image -- under GitHub's one-major-Xcode-per-image model it ships as the separate label `xcode-27` (macOS 27 base, default toolchain Xcode 27.x; preview 2026-07-16, GA 2026-10-15, runner-images #14404; the macos-26 image tops out at 26.6 and macos-15 at 26.3, verified against both image Readmes); build-test, gui, and abi-check matrices therefore gain `xcode-27` as a third `os` value -- `runs-on: ${{ matrix.os }}` carries the label directly, no select step needed, names render as `xcode-27 · Release` / `GUI · xcode-27`; gui's select-newest step picked 27.x non-beta automatically at the time (the next commit replaces discovery on xcode-27 legs with a deliberate Xcode 27.1 pin: the image also ships Xcode 27.2.0 as a name-clean release-candidate symlink); abi-check gains it on purpose: the macOS 27 base means a new kernel/xnu, the exact surface where the FethPair private ABI could drift (the job's own long-term concern); tsan/asan-ubsan/deployment-target stay on macos-26 defaults with a pointed comment; new legs cost +4 macOS jobs per Tests run (11 -> 15), deliberately riding every PR (10x-billed macOS minutes accepted as the price of review-time drift catching); matrix entries documented with label provenance | +| 41 | `fix(ci): make UBSan fatal and run the feth cases under it` | Done | Round-two review, B3. UBSan was report-and-continue: a diagnostic printed, the process kept going, ctest exited 0 through real undefined behavior. cmake/Sanitizers.cmake now appends -fno-sanitize-recover=undefined when TETHERKIT_ENABLE_UBSAN is set; the variable stays empty otherwise, so ASan-only and TSan builds get no extra flag (verified: a full configure with ASan+UBSan on writes -fno-sanitize-recover=undefined into compile_commands.json and flags.make, and c++ compiles a prober with the flag). The asan-ubsan Test step passes UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 for sanitizer runtime pieces the compiler cannot touch, and runs the feth/BPF cases as root after net.link.fake.hwcsum=0; skipped root cases read green while the BPF buffer walk (bh_hdrlen, BPF_WORDALIGN) and the reinterpret_cast-heavy if_fake bridge never reach the sanitizer | +| 42 | `fix(ci): pin Xcode 27.1 on xcode-27 legs; filter RCs elsewhere` | Done | Round-two review, M1. The xcode-27 image ships Xcode_27.2.0 as a release-candidate symlink whose clean name no filter can call out: live run 42491763714 (on e06208a) picked it through a beta-only filter and the leg went green on a pre-release toolchain. build-test and gui now run a Pin step on xcode-27 legs: test -d /Applications/Xcode_27.1.app or error out asking for a deliberate bump, then xcode-select it. macos-15 and macos-26 keep discovery but the filter widens to release candidates (grep -ivE 'beta|release.?candidate'); build.yml's dispatch os choice gains xcode-27 with the same select/pin split. abi-check needs no pin: it never selects an Xcode, so its xcode-27 leg rides the image default | +| 43 | `fix(ci): stop tee and diff from masking the exit status` | Done | Round-two review, B2. GitHub runs shell steps as bash -e with no pipefail, so a pipeline's exit code is its last command's. The abi-check forensic job read tee's always-zero status, so a ctest failure after the pair-creation MESSAGE could hide behind passing evidence; the lint format gate would feed empty stdin to clang-format-diff.py when git diff failed and read the checker's 0 as a formatting pass. Both steps set -o pipefail first, with a comment on each site saying why; lint's base-resolution step already fails the job under -e, the flag closes the same hole independently | +| 44 | `fix(ci): pass inputs by env; preflight the tap; cap the bench` | Done | Round-two review, B1 plus related minors. Inputs and step outputs never interpolate into run: text anymore: bench_args travels as BENCH_ARGS, build.yml reads its dispatch os/build_type through job env DISPATCHED_OS and DISPATCHED_BUILD_TYPE (the upload with: blocks switch to env context so artifact names stay in lockstep with what the shell builds), and update-tap.yml routes inputs.tag and the src step outputs through TAP_TAG/TAP_VERSION/FORMULA_URL/FORMULA_SHA256 in its four shell steps. update-tap gains a Preflight step verifying both formulas exist in justsrc/homebrew-tap before any perl rewrite starts (a missing formula previously died mid-loop with a perl message); release.yml drops secrets: inherit for the explicit HOMEBREW_TAP_DEPLOY_KEY map, keeping every other caller secret out of the callee's scope; bench.yml gets permissions: contents: read and timeout-minutes: 30 | +| 45 | `fix(ci): scope concurrency by event; sync stale comments` | Done | Round-two review minors, plus the ledger corrections. The concurrency group gains github.event_name: a push to main can no longer cancel the in-flight Monday cron, the run that watches runner-image, libusb, and xnu drift. Comments now match what runs: tests.yml's header names four deviations from the ancestor ci.yml (checkout pin, RC filtering, image labels, English abi-check strings) and its trigger rationale says what tag pushes cover and what they skip; the "All 168 cases" claim stopped hard-coding the suite size after the count measured 210; the cron comment documents GitHub's 60-day auto-disable of silent schedules; release.yml's header no longer claims full self-sufficiency and names what a tag run covers and leaves out; AGENTS rows 39 and 40 now reference the landed commits b6135fd and e06208a | +| 46 | `fix(ci): block-scalar the asan-ubsan test command` | Done | Found by the fourth-round ASan+UBSan run: exit 127 on the Test step before a single test ran, with the error reading an env: ctest: No such file or directory whose leading space is the tell. The step had been authored as a folded plain scalar (run: with no \|, three physical lines joined by trailing backslashes), and YAML folds plain-scalar newlines into spaces, so the script became one line where each backslash escapes the following space and glues neighboring words into single tokens; ctest reached sudo env as a command named " ctest". Shell-style line continuations are only meaningful inside a block scalar, which every other continuation-bearing step used already, abi-check included. The step now writes the three lines as a run: \| block mirroring abi-check; verified YAML parses and no other workflow carries a plain-scalar continuation (scan vacuous). The build with -fno-sanitize-recover=undefined succeeded in the same run, the failure was purely this step's syntax | +| 47 | `fix(ci): keep permission cases non-root in asan-ubsan` | Done | Fifth round, run 42504799177 (on 3688329): the asan-ubsan job ran the whole suite as root and the capi permission cases failed on their contract assertions (tests/test_capi.cc:402 and 494): tk_session_start returned 0 and tk_net_apply/tk_net_clear returned -3 where the fixtures assert the documented non-root behavior, TK_ERR_PERMISSION (-4); as root those gates legitimately pass, and tk_cleanup_orphan_interfaces outright succeeded. Copilot's review suggestion to make the tests expect the executing user was rejected: the non-root contract is product behavior (it is what the CLI turns into the please-run-as-root hint), and teaching the CHECKs to accept root would make a future regression to these gates read green. The Test step split in two: the suite as the runner user with UBSAN_OPTIONS as step env (permission cases hold, feth/BPF root cases skip quietly), then a filtered sudo run, ctest -V -R 'net\.' 2>&1 with tee and pipefail, carrying abi-check's guard pair verbatim (skip-marker grep and the created-feth confirmation; strings verified against tests/test_net_link.cc's ReportSkip line 83 and the pair-creation MESSAGE line 435), so a silent skip cannot read green and a genuine sanitizer finding in the BPF buffer walk or the private-ABI bridge fails the step loudly | +| 48 | `fix(ci): declare the tap deploy key on the callee` | Done | Third-round review, N1, referee-verified. release.yml passes the reusable update-tap.yml a named secrets map, which declared only inputs.tag, yet its Set-up-the-deploy-key step expands secrets.HOMEBREW_TAP_DEPLOY_KEY: GitHub validates a named map against on.workflow_call.secrets and an unmatched name fails the run at startup with zero jobs and no step logs (annotation on the caller: Invalid secret, HOMEBREW_TAP_DEPLOY_KEY is not defined in the referenced workflow; Stack Overflow 69493352/76799186); the old secrets: inherit skips that validation entirely, which is why the one-way narrowing broke it, and PR CI cannot see this because workflow_dispatch faces no such declaration: the first real tag push is exactly where it dies. workflow_call now declares HOMEBREW_TAP_DEPLOY_KEY: required: true (dispatch declares no secrets; a workflow_call declaration is where the caller's map is checked) | +| 49 | `fix(ci): scope update-tap's token; key travels by env` | Done | Third-round residuals for update-tap.yml, the one workflow left without permissions or a timeout: it now sets workflow-level permissions: contents: read (the tap push rides the scoped deploy key, so its GITHUB_TOKEN needs no write access anywhere in this repo) and job-level timeout-minutes: 30, matching bench.yml's cap. The Set-up-the-deploy-key step no longer expands the secret raw inside run: text: it travels as step env DEPLOY_KEY and the printf reads "$DEPLOY_KEY", the same env-passage doctrine the src step's comment already stated | +| 50 | `fix(ci): scope lint's concurrency group by event` | Done | Third-round residual: lint.yml's concurrency group was workflow-ref with no event_name, so a push to main and a manual dispatch on the same ref could cancel each other's in-flight lint; tests.yml had gained event_name in f0fbc1d and lint was the holdout. The group now appends github.event_name exactly as tests.yml writes it, with the two-event rationale folded into the concurrency comment | +| 51 | `fix(ci): drop the dead bench args dispatch input` | Done | Third-round residual, addressed rather than left moot: the Run step carried the args dispatch input through an unquoted BENCH_ARGS (intentional word-splitting), but the input itself was dead: benchmarks/ contains no argv or argc use at all (grep-verified before the comment that says so was written), so no dispatch text could ever change the run. workflow_dispatch now declares no inputs, the Run step invokes tetherkit_bench bare behind pipefail+tee, and the inputs.args env-passage comment went with the plumbing it described | +| 52 | `fix(ci): sync the root-leg and style-default comments` | Done | Third-round comment syncs. tests.yml's sanitized root step claimed the net\. filter selects the root-gated feth/BPF cases only, but ctest picks up all three net.* suites (live run 42506526442 executed net.abi, net.loopback, and net.feth there): the comment now says exactly that, net.feth is the one root gates and the other two ride along under the sanitizer build. AGENTS row 39 said the format gate runs `-style file` "since the binary default is LLVM style", contradicting lint.yml's corrected comment (recent clang-format already defaults to file style; the flag survives a future default change): row 39 now carries the corrected rationale. Delegation record: three antigravity/gemini-3.8-flash delegates applied the lint, bench, and tests edits byte-exact within scope; the update-tap edits were applied directly, because the workspace guard blocks non-primary providers from a secret-bearing file, and update-tap.yml is exactly such a file | +| 53 | `docs: correct row 50's event_name provenance to tests.yml` | Done | Unanimous round-three reviewer finding, applied verbatim after re-verification at the source: row 50 credited tests/build with gaining event_name in f0fbc1d, but that commit touched release.yml and tests.yml only, and build.yml has never carried a concurrency block at all (pickaxe for event_name and for concurrency: both empty; it is dispatch-only with nothing to cancel). Row 50 now attributes the earlier scoping to tests.yml alone; the lint.yml workflow change itself was always correct and needed no second look. Non-blocking doc-only fix | +| 54 | `fix(ci): force arm64 target architecture in codeql build` | Done | Diagnosed CodeQL run 42513807606 (2026-11-08): job failed with linker error "Undefined symbols for architecture x86_64" and "found architecture 'arm64', required architecture 'x86_64'" against Homebrew's /opt/homebrew/Cellar/libusb/1.0.30/lib/libusb-1.0.dylib. CodeQL's macOS tracer forces traced child processes onto the x86_64 slice under Rosetta; without an explicit target architecture, AppleClang emits x86_64 objects that cannot link against arm64-only Homebrew dependencies. `codeql.yml` pins `CMAKE_OSX_ARCHITECTURES=arm64` so clang cross-emits arm64 regardless of driver process architecture; `arch -arm64` cannot be used instead because child builds forced native bypass CodeQL tracing. Hardened `codeql.yml` with a `pull_request` trigger scoped to `.github/**` so workflow edits self-verify. Verified green by two follow-up runs on the branch -- 42578919426 (pull_request event) and 42578945162 (workflow_dispatch; 11m59s C++-analysis job span, 12m5s run wall, Build and analyze clean against arm64 Homebrew libusb) -- sealing the failure-plus-verification pairing exactly like rows 39/41/42/47. | +| 55 | `fix(ci): narrow codeql pr trigger, event-scope concurrency` | Done | Review round on row 54, five findings, four applied in codeql.yml plus this row's amendment: (1, consensus) pull_request paths narrowed `.github/**` -> `.github/workflows/**` - dependabot.yml and .github/scripts/ edits never reach the analyzer, while Dependabot's weekly grouped action-bump PR still matches by construction (it edits workflow files) and the header comment now states that ride-along as deliberate; (2, sharpest catch) the header's "No concurrency block: canceling would throw away the whole analysis" sentence predated the PR trigger and no longer held - a superseded PR commit's SARIF is simply replaced by the newer run - so an event-scoped concurrency group mirroring tests.yml landed (group `${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}`, cancel-in-progress: true) with a comment explaining why cancellation is safe within one event and why event_name in the key keeps a push to main from canceling the Monday cron; (3) row 54 amended (above) to cite verification runs 42578919426 and 42578945162, sealing the failure-plus-verification pairing the ledger's audit value depends on; (4, nit) the Build step comment now carries the hard-coded-on-purpose guard - under the tracer `uname -m` reports x86_64, so a `-DCMAKE_OSX_ARCHITECTURES=$(uname -m)` "simplification" would silently reintroduce the bug (the deliberate-choices-need-a-magnitude rule from pitfall 16); (5, INFO) the first-ever green analysis surfaced one pre-existing `cpp/use-after-free` at vendored third_party/doctest/doctest.h:3710 - resolved as a documented decline rather than config: `paths-ignore` is verifiably inert for C/C++ manual builds (codeql team statement in github/codeql-action#3890; GitHub docs confine paths filtering to build-mode none; the traced run would warn "path-based filtering not supported"), and SARIF post-filtering via advanced-security/filter-sarif means a new third-party action for exactly one benign vendored alert - the workflow header records the accepted baseline instead; the fork-PR-403 finding was retracted by the reviewers against current GitHub docs (SARIF upload on pull_request events is permitted under read-only fork tokens). Two disjoint antigravity/gemini-3.8-flash delegates applied the codeql.yml and AGENTS.md edits byte-exact within disjoint file scopes. Verification: the PR-triggered analysis run 42583242299 is green on 75634ad, and the new group's cancel-in-progress proved itself on this very push - the in-flight run 42582860934 for the superseded commit 84d8151 was canceled and replaced by the 75634ad run. | +| 56 | `feat(i18n)!: drop the Chinese column, keep the single-language wiring` (this branch) | Done | Maintainer decision: English-only, but preserving the multi-language plumbing so adding a language stays a bounded five-touch change (checklist now lives at the top of messages.def). messages.def went 843 -> 529 lines via a string-aware perl splice (TETHERKIT_MESSAGE(id, zh, en) -> (id, en)), 310 records; guards on kept column: no Han, no full-width punctuation. i18n.h/i18n.cc rewritten in English: Language enum reduced to kEnglish=0, single kMessageTable, TextIn kept as the designed per-language dispatch point, ParseLanguage no longer accepts zh/Chinese (returns false, CLI rejects with new English message), LanguageFromLocaleString parked at "always English" with the prefix rule to return there, ContextSeparator fixed to ": ". C ABI signature-stable: TK_LANGUAGE_CHINESE removed; ToLanguage returns nullopt for unknown values so a caller still sending the old Chinese value 1 is ignored and stays in English; helper XPC setLanguage method and protocolRevision 3 untouched (still applied, inert). Four ScopedLanguage guard sites flipped kChinese->kEnglish with English expectations (bridge stats "queue depth", CONDIS reject "connection-oriented", keepalive fatal "keep-alive", MalformedReason "none"); test_net_link's sysctl-why bilingual loop collapsed to the single table. test_common_i18n.cc rewritten: per-string placeholder well-formedness, no mixed indexing, contiguous indices 0..n-1; the cross-language parity checks were retired and are documented to come back when a second language lands. CLI help no longer advertises zh. Verification: cmake -Werror build clean, ctest 23/23 suites pass, smokes green (--version; --help shows the reworded --lang lines; --lang zh --list errors in English; --lang en --list and LC_ALL=zh_CN --list render English). GUI side deliberately untouched this phase, so `swift test` still exercises bilingual Swift tables and must be green throughout; the Swift strip is the next commit. Splice/validation scripts live in the session temp dir (not in the repo). Untracked docs/icon_resources/ and draft_icons/ predate this work and were left alone. | +| 57 | `feat(i18n)!: strip the Swift side to English-only, keep the add-a-language wiring` (this branch) | Done | Phase 2 complement to row 56. LocalizedStrings.swift went 743 -> 685 lines via a string-aware perl splice: every `localizations` switch row lost its Chinese first argument, with two guards - the dropped arg must contain Han or full-width punctuation OR be identical to the kept one (the languageEnglish native-name row needs the second rule), and the kept column must be Han- and fullwidth-free; kept spans are preserved byte-exact, including multi-line `+`-concatenation layouts (installer/update/permission strings); the file header and 42 MARK/comment lines went to English via an exact-line map; the `languageChinese` key is gone entirely (both Pickers hard-code `Text(verbatim: "English")` under the tag, so the key had no non-test consumers). Language enum is `.english` only with `cValue` constant 0; LanguagePreference loses `.chinese`, so a stale saved rawValue "chinese" decodes to nil and falls back to `.system` (graceful, symmetric with the C ABI ignoring unknown values); `L10n.apply` and `text(_:in:)` survive as the add-a-language dispatch points; new internal `L10n.localeLanguage` is parked always-English, exactly mirroring LanguageFromLocaleString on the C++ side, and `systemLanguage` still reads Locale.preferredLanguages through it; HelperService.setLanguage keeps its ignore-unknown semantics (an old App still sending "chinese" leaves the helper in English); both language menus now offer Follow system + English. **Swift constraint determined empirically** (tiny probe program): single-element labeled tuples are illegal - `cannot create a single-element tuple with an element label` - so the planned `(english: String)` one-member tuple is unrepresentable and rows are bare strings; when a language is added the property becomes a real labeled tuple and every case appends one labeled argument, the same way C++ rows `(id, en)` grow. LocalizationTests rewritten for the single table, still 7 cases: per-key placeholder contiguity, the %ld-integer rule, language resolution incl. localeLanguage, and formatting substitution; the cross-language placeholder/parity checks retire until a second language and the suite header says they return then. Verification: swift build green (3.3s, only the pre-existing libtetherkit dylib-version warning); swift test 30/30 - TetherKitIPCTests 17/17 (Authorization 2 + HelperConstants 8 + the 7 rewritten LocalizationTests) and TetherKitUIModelTests 13/13; the current-state snapshot below says "17 IPC + 7 UIModel = 24", which predates the sidebar test additions, not this work. Also folded in here: the row-56 typo fix (i18n.cci -> i18n.h). Splice/unwrap scripts live in the session temp dir. | +| 58 | `feat(i18n)!: translate every Chinese comment and user-facing benchmark string to English` (this branch) | Done | P3 = the comment-and-doc-string half of the English-only migration (P1 = C++ message table, P2 = Swift string table). Scope: ~100 files, every Han comment in `include/`, `src/`, `tests/`, `benchmarks/`, `gui/` plus the benchmark tool's own report output; `docs/` + `README` + `AGENTS.md` are P4. Work was delegated in disjoint batches through the T3 `delegate_task` tool on the three OpenCode models the maintainer named (iceman-free, minimax-m3.1-flash-free, mimo-v2.6-flash-free; MiMo hit a free-tier limit mid-run and its remainder was re-dispatched to MiniMax); every batch was verified before acceptance, and three were sent back or fixed by hand. Verification method, all in-session: a comment-only scope proof (comments stripped from HEAD and from the tree, then the code token streams compared -- byte-identical for every file except the deliberately allowed literals), a comment-line-count drop check (a translation must not shrink a file's prose), a repo-wide Han census, the CI lint gate reproduced locally (vendored `.github/scripts/clang-format-diff.py` over the changed hunks, base HEAD), a full `-Werror` build plus 23/23 ctest, and `swift build`/`swift test` for the GUI batches. Findings worth keeping: (1) the house style is **pure ASCII in comments** -- the earlier crew had already converted box-drawing to `+ - |` in `feth_device.h` and dropped the `star`/`warning` markers, so two batches that kept them were normalized afterwards (emphasis now lives in the prose); (2) the CI format gate is **line-scoped**, so touching a test title drags its whole `TEST_SUITE` block into scope and clang-format then wants it re-indented by 2 -- the Chinese-era test files had their cases at column 0 and were never format-clean; the reformat was applied with an invariant that the **code token stream** is unchanged (a re-indent or a re-wrap may move a line break, never a token) and that the **comment word stream** is unchanged (a long comment may be reflowed, never reworded); (3) `src/app/main.cc` had to have its include blocks reordered (project headers before system headers, per `.clang-format`) -- pre-existing baseline debt that only became visible once a comment above it changed; (4) `docs/BENCHMARKS.md` is **half generated**: its first part is the literal output of `tetherkit_bench`, so the tool's report strings had to be translated too (they are user-facing) and the document's generated part must use exactly the wording the tool now prints -- but the document must NOT be regenerated, because that would drop the root-measured feth/BPF numbers and the hand-written analysis; (5) the `device` fixture in `tests/test_capi.cc` was **swapped, not translated**: it is a byte-pattern fixture (two 3-byte UTF-8 characters) and became two U+20AC characters (the euro sign, 3 bytes each, 6 bytes total) with every byte count in the assertions preserved; (6) the two CI grep anchors in `tests/test_net_link.cc` (the `ReportSkip` format string and the `created {} <-> {}` message) are byte-exact and must stay so; (7) a *passing* test is not proof -- a log-text needle such as `find("libusb initialized")` must match the production message table exactly or the assertion passes vacuously; (8) the Chinese strings in `tests/test_common_error.cc` are **paired** (a context string and the assertion on it) and were translated on both sides with identical wording, which is why the brief for that batch had to be corrected before dispatch. The GUI language plumbing, the XPC protocol revision 3 and the C ABI are untouched by this phase | +| 59 | `docs(i18n)!: translate the documentation to English and drop the Chinese README` (this branch) | Done | P4 = the documentation half of the English-only migration (P3 = code comments). Scope: `AGENTS.md` (857 -> 487 lines), the seven `docs/*.md` files, `README.md`, and the deletion of `README.zh-CN.md`. Delegated in four disjoint batches through the T3 `delegate_task` tool on the three OpenCode models the maintainer named; the README edits and the Chinese-README deletion were done by the maintainer. Verification, per file: a numeric-token multiset comparison against HEAD (every measurement preserved; the only differences are three correct `wan`-unit conversions in BENCHMARKS.md -- `124 wan` -> "1.24 million frames per second", `2.7 wan` -> "27,000 frames per second", `46 wan` -> "460,000 frames per second"), a fence-aware structural comparison (headings, table row counts and per-table pipe counts identical in every file), a link-resolution check, and a repo-wide Han scan that reaches zero. Findings worth keeping: (1) `docs/BENCHMARKS.md` is **half generated** -- its first part is the literal output of `tetherkit_bench`, so it must use exactly the wording the tool now prints, and it must **not** be regenerated (that would drop the root-measured feth/BPF numbers and the hand-written analysis); the tool's English output was captured and used as the reference; (2) a document that quotes GUI strings must quote them **verbatim**: four quotes in GUI-ARCHITECTURE.md did not match the table ("Check for Updates..." vs the real "Check for Updates...", "Uninstall the privileged helper..." vs "...helper?", the invented "Install / update the privileged helper" button, and a `%ld` template rendered as a literal) and were corrected against `LocalizedStrings.swift`; (3) **ASCII-only yields to fidelity** when the text is a quotation of a real string or a literal that carries meaning: GUI-SPIKE.md keeps a full-width parenthesis in its bash snippet because the documented `0xEF` lead-byte trap depends on it (the reproduction was re-verified: `IFACE?: unbound variable`), and the two UI titles above keep the app's real ellipsis and question mark; (4) the AGENTS.md ledger rows and the pitfall list keep their exact numbering (the 58 pre-existing rows, plus this one; pitfalls 1..24 plus 4b), so the code's citation "AGENTS.md section 7, pitfalls 12 and 13" still resolves to the same two pitfalls; the emoji status marks became the word "Done" table-wide (row 58 included, its prose untouched), and the stale test count was corrected from 24 to the measured 30 (17 TetherKitIPC + 13 TetherKitUIModel); (5) the AGENTS.md directory tree no longer lists the deleted `README.zh-CN.md`, while ledger row 38 keeps the mention as a historical record of what that commit did; (6) the i18n section was adapted to the single-language reality while naming the retained plumbing and its five-touch checklist. Nothing pushed | + +### Current state + +- **Tests**: all 23 ctest cases pass; on the GUI side `swift test` has 30 cases passing (TetherKitIPCTests 17 and TetherKitUIModelTests 13, including LocalizationTests and HelperConstantsTests). +- **Build**: zero warnings under `-Werror`; the CLI, shared library, and GUI products all build. +- **CI**: legacy ci.yml split into tests.yml (five jobs migrated with their steps and checks unchanged, triggers PR + push main + Monday cron + ASan/UBSan), build.yml (manual trigger only, full build + artifacts) and lint.yml (clang-format gate + advisory clang-tidy); new codeql.yml (weekly + push main C++ scanning), bench.yml (manual benchmarks) and dependabot.yml (weekly grouped action bumps); permissions: contents: read across tests/lint/build; Xcode selection filters beta; every action pinned to the latest major (checkout@v7, upload-artifact@v7). +- **Runnable**: `--version` / `--help` / `--list` all work; starting without root prints a clear hint and returns exit code 1; `TetherKit.app` and `tetherkit-helper` both start after packaging; the privileged helper can be installed / uninstalled inside the App with one click (the install path has been exercised on real hardware); when the helper version does not match the App, the management row lights up "Update the helper" (it compares the **library** version, not Info.plist -- `brew upgrade` only replaces the .app, the copy installed under /Library does not follow; when the protocol revision did not change there was previously no prompt at all, and the only thing the user could notice was "the issue described as fixed in the release notes is still there"; the windows were screenshot-checked); English-only behavior is verified: CLI `--help` / `--list` / argument errors are correct, and switching the language in the GUI menu updates the main window and the App menu **immediately** (no restart required, screenshot-checked). +- **Verified**: the USB side works on a real RNDIS device (enumeration, interface claim, RNDIS handshake), and this USB side **does not require root**; the feth private ABI, the two private BPF ioctls, and the core assumption that "a BPF `write()` lets the peer IP stack receive a frame" have all been confirmed on real hardware. See section 6. +- **Benchmarked** (2026-07-28, Android USB tethering): end-to-end iperf3 measured **RX 324-327 Mbps / TX 234-241 Mbps** (USB 2.0 High Speed, theoretical ceiling 426 Mbps); the full GUI-to-real-device link works (plug in -> connect -> obtain a DHCP address -> bidirectional ping). See section 6.4 and [docs/BENCHMARKS.md](docs/BENCHMARKS.md). +- **Fixed**: the root cause of TX frame drops under high load has been identified -- the bridge layer dropped the rest of the batch immediately when the bulk OUT transfer pool was full, even though a slot would become available in a few hundred microseconds. After changing to wait: TX frame drops 1889 -> 0, TCP TX retransmits 3829 -> 0, bidirectional concurrent TX 4.8 -> 87 Mbps; Ctrl-C graceful shutdown was rechecked after the fix (printed "stopped", no feth residue). See section 6.5. +- **Fixed** (2026-07-28): after connecting, the GUI device list degraded "vivo iQOO Z10x + serial number" to "USB device 2d95:600b" and kept it for the whole session -- one enumeration in the startup race window could not read the string descriptors, and the empty strings overwrote good data. The fix is backfill rather than blocking the window: capi remembers the last successfully read strings for each device and backfills when a read comes up empty; the helper's occupancy check was fixed in sync (the handshake window counts as occupied; failed/stopped dead sessions do not). Rechecked on real hardware (names complete when read_strings=false). See pitfall 17 in section 7. +- **Still unresolved**: TCP TX shows a bimodal pattern (two stable states around 240 / ~300 Mbps, both with zero retransmits); under bidirectional concurrency RTT still inflates from 0.4 ms to 16.6 ms (it no longer causes throughput collapse, but how much reducing RX queue depth would help has not been measured). + +### GUI notes + +**Before changing the GUI, the C ABI, or the Runtime threading model, read `docs/GUI-ARCHITECTURE.md`.** +It records a set of constraints that "do not fail immediately when done wrong, but fail in ways that are very hard to debug": UTF-8 boundary truncation, enum-order coupling, `cp -a` vs symlinks, mandatory re-signing after install_name_tool, the three authorization-recheck flags, pipe-deadlock read/write ordering, and so on. + +## 6. Verification checklist (needs root or a real RNDIS device) + +### 6.1 Verified: USB side (**no root required**) + +Measured on a real RNDIS device. The conclusions are recorded as platform facts -- they reflect macOS/libusb behavior, not a specific device model. + +**(1) As a non-root user, `libusb_open` does not return `LIBUSB_ERROR_ACCESS`; it succeeds directly.** + +The earlier assumption was wrong. Opening a USB device on macOS **does not require root** (unlike the Linux udev permission model). The entire USB side -- enumeration, `libusb_open`, interface claim, transfer I/O -- can run as a normal user; root is needed **only** to create feth and open `/dev/bpf*`. `--list` therefore states explicitly that it "does not require root". + +**(2) Warning: `libusb_kernel_driver_active() == 1` on macOS does not predict that `claim` will fail.** + +This is the easiest mistake to make in this section. Measured on the same composite device: + +| Interface signature | Matched driver | `kernel_driver_active` | `libusb_claim_interface` | |---|---|---|---| -| `02/02/ff` RNDIS 通信 | **无** | 0 | ✅ 成功 | -| `0a/00/00` RNDIS 数据 | `AppleUserECMData`(DriverKit dext) | **1** | ✅ **仍然成功** | -| `02/02/01` CDC-ACM 控制 | `AppleUSBACMControl`(kext) | 1 | ❌ `LIBUSB_ERROR_ACCESS` | -| `03/00/00` HID | `AppleUserUSBHostHIDDevice` | 1 | ❌ `LIBUSB_ERROR_ACCESS` | -| `ff/42/01` 厂商自定义 | 有 | 1 | ❌ `LIBUSB_ERROR_ACCESS` | +| `02/02/ff` RNDIS communication | **None** | 0 | Succeeds | +| `0a/00/00` RNDIS data | `AppleUserECMData` (DriverKit dext) | **1** | **Still succeeds** | +| `02/02/01` CDC-ACM control | `AppleUSBACMControl` (kext) | 1 | `LIBUSB_ERROR_ACCESS` | +| `03/00/00` HID | `AppleUserUSBHostHIDDevice` | 1 | `LIBUSB_ERROR_ACCESS` | +| `ff/42/01` vendor-specific | Present | 1 | `LIBUSB_ERROR_ACCESS` | -`kernel_driver_active` 只反映 IOKit 里有驱动**匹配(matched)**,**不**反映该驱动是否 -**独占持有**接口。DriverKit dext(`com.apple.DriverKit.AppleUserECM`)匹配上 `0a/00/00` -数据接口后并不独占,claim 照样成功;而 kext(ACM/HID)是真独占。 +`kernel_driver_active` only reflects that a driver **matched** the interface in IOKit; it does **not** indicate whether that driver **exclusively holds** the interface. After matching the `0a/00/00` data interface, the DriverKit dext (`com.apple.DriverKit.AppleUserECM`) does not hold it exclusively, so claim still succeeds; kexts (ACM/HID) do hold exclusively. -**所以:不要拿 `kernel_driver_active` 做预检并提前报错**,那会把本来能用的设备误判成 -不可用。唯一可靠的判定是直接 claim 看返回值。又因为 macOS 上没有 -`libusb_detach_kernel_driver`(见第 7 节第 7 条),claim 失败就确实无解。 +**Therefore: do not use `kernel_driver_active` as a preflight check and fail early**; that would misclassify a usable device as unusable. The only reliable test is to claim and inspect the return value. And because macOS has no `libusb_detach_kernel_driver` (see pitfall 7 in section 7), a claim failure really is unrecoverable. -好消息是 RNDIS **通信**接口(`02/02/ff`)压根没有驱动匹配 —— 印证了「macOS 没有 -RNDIS 内核驱动」这个立项前提。 +The good news is that the RNDIS **communication** interface (`02/02/ff`) has no driver match at all -- confirming the project-start assumption that "macOS has no RNDIS kernel driver". -复现方法:`--list` 看识别结果;接口级细节用 `ioreg -c IOUSBHostInterface -r -l` -查驱动匹配,再用一段十几行的 libusb 程序逐接口 `claim` 看返回值。 +How to reproduce: use `--list` to see recognition results; for interface-level detail, inspect driver matches with `ioreg -c IOUSBHostInterface -r -l`, then claim each interface in a short libusb program of about a dozen lines and inspect the return values. -### 6.2 已验证:feth / BPF 侧(**需 root**) +### 6.2 Verified: feth / BPF side (**requires root**) -③④⑦ 的复现方法:`sudo TETHERKIT_ROOT_TESTS=1 build/bin/tetherkit_tests --test-suite=net.feth` -(不设该环境变量时这些用例**跳过而非失败**,跳过原因会打印出来),以及直接 -`sudo build/bin/tetherkit-cli` 跑一次。⑤⑥ 目前**没有**进测试套件,是用一次性探针测的 -(做法写在各条里,⑥ 的缺口已记进 6.3)。 +How to reproduce (3)(4)(7): `sudo TETHERKIT_ROOT_TESTS=1 build/bin/tetherkit_tests --test-suite=net.feth` (when that environment variable is not set, these cases **skip rather than fail**, and the skip reason is printed), or run `sudo build/bin/tetherkit-cli` directly once. (5)(6) are **not** in the test suite yet; they were measured with one-off probes (the method is described in each item; the gap for (6) was recorded in 6.3). -**③ `feth` 配对的私有 ABI 在 macOS 26 上可用。** +**(3) The private feth-pairing ABI works on macOS 26.** -`SIOCSDRVSPEC` + `struct if_fake_request` 这条路径完全成立:创建、配对、设 MTU/MAC、 -UP、销毁全流程通过。第 2 节里用 `static_assert` 钉死的那些 ioctl 编号和结构体大小 -是对的 —— 编号算错的话这里会直接失败。 +The `SIOCSDRVSPEC` + `struct if_fake_request` path is fully valid: create, pair, set MTU/MAC, bring UP, and destroy all pass. The ioctl numbers and struct sizes pinned down with `static_assert` in section 2 are correct -- if a number were wrong, this would fail directly. -**④ 两个私有 BPF ioctl 在 macOS 26 上都可用。** +**(4) Both private BPF ioctls work on macOS 26.** -- `BIOCSBATCHWRITE`(一次 `write()` 发多帧)→ 生效,运行日志里显示「批量写 已启用」。 - 这坐实了第 7 节第 4b 条的更正:「Darwin BPF 不支持批量写」确实是错的。 -- `BIOCSNOTSTAMP`(关每帧时间戳)→ 生效,日志显示「时间戳 已关闭」。 +- `BIOCSBATCHWRITE` (send multiple frames in one `write()`) -> takes effect; the runtime log shows "batch write enabled". + This confirms the correction in pitfall 4b of section 7: "Darwin BPF does not support batch writes" was indeed wrong. +- `BIOCSNOTSTAMP` (disable per-frame timestamps) -> takes effect; the log shows "timestamps disabled". -**⑤ `BIOCSBLEN` 的实际上限是 32 MiB,`debug.bpf_maxbufsize` 不是它的约束值。** +**(5) The effective `BIOCSBLEN` ceiling is 32 MiB; `debug.bpf_maxbufsize` is not its constraint.** -按 `BIOCSBLEN` → `BIOCSETIF` → `BIOCGBLEN` 的顺序实测(即挂上接口后读回**有效**值): +Measured in `BIOCSBLEN` -> `BIOCSETIF` -> `BIOCGBLEN` order (reading back the **effective** value after attaching to the interface): -| 请求 | 挂接口后的有效值 | +| Requested | Effective value after attaching | |---|---| -| 4 KiB / 1 / 4 / 8 / 16 / 32 MiB | 原样生效,一字不改 | -| 64 MiB、2 GiB | 都被钳到 **32 MiB**(`0x2000000`) | +| 4 KiB / 1 / 4 / 8 / 16 / 32 MiB | Takes effect unchanged, byte for byte | +| 64 MiB, 2 GiB | Both clamped to **32 MiB** (`0x2000000`) | -⚠️ 反直觉的一点:`sysctl debug.bpf_maxbufsize` 在本机报的是 **512 KiB**, -比实际能设的小 64 倍 —— **别拿这个 sysctl 去推 `BIOCSBLEN` 的上限**,对不上。 -(`debug.bpf_bufsize` 报的 4 KiB 倒确实是默认值。) -另外 32 MiB 只是「设得进去」,不等于「值得设这么大」:默认的 4 MiB 已经够用。 +Warning -- counterintuitive: `sysctl debug.bpf_maxbufsize` reports **512 KiB** on this machine, 64 times smaller than what can actually be set -- **do not use this sysctl to infer the `BIOCSBLEN` ceiling**, it does not match. (`debug.bpf_bufsize` does correctly report the 4 KiB default.) Also, 32 MiB being "settable" is not the same as "worth setting that high": the default 4 MiB is already enough. -**⑥ BPF `write()` 确实能让对侧 feth 的 IP 栈收到帧 —— 整个方案的核心前提成立。** +**(6) A BPF `write()` really does let the peer feth's IP stack receive a frame -- the core assumption of the whole approach holds.** -用 ARP 往返闭环验证:系统侧 feth 配 `10.99.99.1`,从**驱动侧** feth 的 BPF 写一个 -问 `10.99.99.1` 的 ARP 请求,随即在同一个 BPF 上读到了系统侧 IP 栈发回的 -**ARP reply**(源 MAC 正是系统侧 feth 的 MAC,宣称拥有该 IP)。 +Verified with an ARP round-trip loop: the system-side feth is assigned `10.99.99.1`; from the **driver-side** feth's BPF we write an ARP request asking for `10.99.99.1`, and immediately read back an **ARP reply** from the system-side IP stack on the same BPF (source MAC is exactly the system-side feth's MAC, claiming to own that IP). -这一个实验同时证明了两件事: +This single experiment proves two things at once: -- `write()` 到驱动侧 → 帧穿过 feth 对 → **对侧 IP 栈真的收到并处理了**(否则不会应答); -- `BIOCSSEESENT=0` 的回环抑制正确 —— 读回来的只有 input 方向的 reply, - 没有我们自己刚写进去的那个 request。 +- `write()` to the driver side -> the frame crosses the feth pair -> the **peer IP stack really receives and processes it** (otherwise it would not reply); +- loopback suppression with `BIOCSSEESENT=0` is correct -- only the input-direction reply is read back, not the request we just wrote. -⚠️ 但这条路有个前置条件,见第 7 节第 14 条(`BIOCSHDRCMPLT`)。 +Warning: this path has a prerequisite; see pitfall 14 in section 7 (`BIOCSHDRCMPLT`). -**⑦ 端到端:RNDIS 握手 → feth 建对 → BPF 挂载 → 优雅停机,全程跑通。** +**(7) End to end: RNDIS handshake -> feth pair creation -> BPF attach -> graceful shutdown, the full path works.** -一次完整启动的关键节点:声明接口拿到 bulk IN/OUT(`wMaxPacketSize` 512)与中断 IN → -RNDIS 协商完成(版本 1.0、MTU 1500)→ 查到设备 MAC 与链路速率 → 状态机进入 -「数据已就绪」→ feth 对创建并配对、系统侧 MAC 改为设备 MAC → BPF 挂上驱动侧。 +Key milestones from one complete startup: interface claim yields bulk IN/OUT (`wMaxPacketSize` 512) and interrupt IN -> RNDIS negotiation completes (version 1.0, MTU 1500) -> device MAC and link speed are read -> the state machine enters "data plane ready" -> the feth pair is created and paired, the system-side MAC is changed to the device MAC -> BPF attaches to the driver side. -`SIGTERM` 后拆除顺序正确,**feth 接口无残留,默认路由全程未被改动**。 +After `SIGTERM` the teardown order is correct, **no feth interfaces are left behind, and the default route was never changed**. -### 6.3 待验证 +### 6.3 Still to verify -原先的三项**已全部完成**,结论并入 6.4: +The original three items are **all complete**; conclusions are folded into 6.4: -- [x] 端到端吞吐 → RX 324~327 Mbps / TX 234~241 Mbps,见 6.4。 -- [x] `net.feth` 套件缺 ARP 往返闭环 → 已补成 root 用例 - 「ARP 往返闭环:BPF 写入的帧确实进了对侧 feth 的 IP 栈」,真机上通过。 - 第 6.2 节 ⑥ 的结论不再只活在文档里。 -- [x] Android 的 RNDIS quirk → 见 6.4「设备汇报的参数」。 +- [x] End-to-end throughput -> RX 324-327 Mbps / TX 234-241 Mbps, see 6.4. +- [x] The `net.feth` suite lacked an ARP round-trip loop -> added as a root test case "ARP round-trip loop: a frame written by BPF really enters the peer feth's IP stack", passing on real hardware. The conclusion in 6.2 (6) no longer lives only in this document. +- [x] Android RNDIS quirk -> see "device-reported parameters" in 6.4. -- [x] TX 丢帧的**根因** —— 已查明并修复,见 6.5。 -- [x] 修复后的优雅停机 —— 在真正的终端里 `sudo build/bin/tetherkit-cli` 再 Ctrl-C - (SIGINT,与 SIGTERM / SIGHUP 共用同一个处理器):打出「已停机」, - `ifconfig` 无 feth 残留。**新增的背压等待没有卡住停机路径。** - 注意这一条只能在真终端里验证,走提权脚本会被吞掉信号,见第 7 节第 15 条。 +- [x] **Root cause** of TX frame drops -- identified and fixed, see 6.5. +- [x] Graceful shutdown after the fix -- in a real terminal, `sudo build/bin/tetherkit-cli` then Ctrl-C (SIGINT, sharing the same handler as SIGTERM / SIGHUP): printed "stopped", `ifconfig` showed no feth residue. **The newly added backpressure wait did not block the shutdown path.** Note this can only be verified in a real terminal; an elevation script swallows the signal (see pitfall 15 in section 7). -仍然待查: +Still unresolved: -- [ ] TCP TX 的双峰行为(约 240 vs 约 300 Mbps 两个稳定态,都是零重传)。 -- [ ] 双向并发下 RTT 从 0.4 ms 膨胀到 16.6 ms;已不再造成吞吐塌陷, - 但缩小 RX 排队深度能改善多少未测。 -- [ ] 静态 IP 模式下 DNS 是否真的被 IPMonitor 采纳(见 docs/GUI-ARCHITECTURE.md)—— - 本轮只验证了 DHCP 路径。 +- [ ] The bimodal TCP TX behavior (two stable states around 240 vs ~300 Mbps, both with zero retransmits). +- [ ] RTT still inflates from 0.4 ms to 16.6 ms under bidirectional concurrency; it no longer causes throughput collapse, but how much reducing RX queue depth would help has not been measured. +- [ ] Whether DNS is actually adopted by IPMonitor in static-IP mode (see docs/GUI-ARCHITECTURE.md) -- only the DHCP path was verified this round. -### 6.4 已验证:真机端到端(Android USB 网络共享) +### 6.4 Verified: end-to-end on a real device (Android USB tethering) -**设备**:vivo iQOO Z10x(V2445A / PD2445M),Android 15(SDK 35), -USB `2d95:600b`,接口签名 `e0/01/03`(无线类 RNDIS 变体),协商 USB 2.0 高速。 -macOS **没有**为它创建任何网络接口 —— 再次印证「macOS 无 RNDIS 驱动」这个立项前提。 +**Device**: vivo iQOO Z10x (V2445A / PD2445M), Android 15 (SDK 35), USB `2d95:600b`, interface signature `e0/01/03` (wireless-class RNDIS variant), negotiated USB 2.0 High Speed. macOS **did not create any network interface** for it -- again confirming the project-start assumption that "macOS has no RNDIS driver". -**设备汇报的参数**(这就是原先想验证的 Android quirk): +**Device-reported parameters** (the Android quirk we originally wanted to verify): ``` -RNDIS 协商完成:版本 1.0,MTU 1500,设备聚合上限 15800 字节 / 10 包,TX 对齐 1 字节 +RNDIS negotiation complete: version 1.0, MTU 1500, device aggregation limit 15800 bytes / 10 packets, TX alignment 1 byte ``` -即 `MaxPacketsPerMessage=10`、`MaxTransferSize=15800`、`PacketAlignmentFactor=0`。 -**与主线 Linux gadget 的 `1 包 / 1580 字节` 差了 10 倍** —— messages.cc 里 -「打了聚合补丁的 Android 内核报 3 或更多」那条注释得到了真机证实。 -没有出现异常值,现有的钳位逻辑全程没有触发。 +That is, `MaxPacketsPerMessage=10`, `MaxTransferSize=15800`, `PacketAlignmentFactor=0`. **10x the mainline Linux gadget's `1 packet / 1580 bytes`** -- the comment in messages.cc that "an Android kernel with the aggregation patch reports 3 or more" was confirmed on real hardware. No abnormal values appeared, and the existing clamping logic was never triggered. -⚠️ 顺带一条:**设备每次会话汇报的 MAC 都不同**(Android 随机化 RNDIS MAC), -所以别把设备 MAC 当作稳定标识来缓存。 +Warning: **the device reports a different MAC every session** (Android randomizes the RNDIS MAC), so do not cache the device MAC as a stable identifier. -**吞吐**:RX 326~327 Mbps(0 重传)/ TX 239~302 Mbps(0 重传,修复后)。 -完整证据链、A/B 数据与机理分析都在 -[docs/BENCHMARKS.md](docs/BENCHMARKS.md) 的「真机端到端实测」一节。 +**Throughput**: RX 326-327 Mbps (0 retransmits) / TX 239-302 Mbps (0 retransmits, after the fix). The full evidence chain, A/B data, and mechanism analysis are in the "End-to-end measurements on a real device" section of [docs/BENCHMARKS.md](docs/BENCHMARKS.md). -⚠️ **排查陷阱**:手机侧 `/proc/net/dev` 的 `rndis0` `rx_errs` 计数器**不可信** —— -它会给每一帧都记一次错误(50 个 ping 全部得到回应,`rx_pkts` 和 `rx_errs` 却同时 +51)。 -排查 TX 问题请以 iperf3 的实际丢包率和 TetherKit 自己的统计为准。 +Warning -- **debugging trap**: the `rndis0` `rx_errs` counter in `/proc/net/dev` on the phone side is **unreliable** -- it records an error for every frame (50 pings all received replies, yet `rx_pkts` and `rx_errs` both increased by 51). When investigating TX issues, rely on iperf3's actual loss rate and TetherKit's own statistics. -### 6.5 已修复:TX 高负载丢帧 +### 6.5 Fixed: TX frame drops under high load -**根因一行话**:`Bridge::RunTransmitExtractor` 在 USB 传输池占满时**立即丢弃** -本批剩余帧,而槽位只需等几百微秒就会释放。 +**Root cause in one line**: `Bridge::RunTransmitExtractor` **immediately dropped** the rest of the batch when the USB transfer pool was full, even though a slot would become available in a few hundred microseconds. -旧代码在 `SendFrames` 返回 0 时 `AddDroppedFull(剩余) + break`,理由是 -「等待会让 BPF 内核缓冲堆积成**看不见的**内核丢包,不如在用户态丢并计数」。 -两个前提都不成立:`bs_drop` 每批都被 `ReadFrames` 读回来、就显示在统计行的 -「内核丢包」列里;而 4 MiB 的内核缓冲(250 Mbps 下约 130 ms 弹性)**正是** -该吸收突发的那一级。量级更能说明问题:设备汇报 15800 字节 / 10 包,一个 256 帧 -的提交分片需要约 **26 个槽**,池里只有 **4 个** —— 每个分片必然在第 4 个槽之后 -撞上背压,剩下 200 多帧当场丢弃。 +The old code did `AddDroppedFull(remaining) + break` when `SendFrames` returned 0, with the rationale "waiting would let the BPF kernel buffer pile up into **invisible** kernel drops; better to drop in userspace and count it". Both premises were wrong: `bs_drop` is read back by `ReadFrames` on every batch and is shown in the "kernel drops" column of the statistics line; and the 4 MiB kernel buffer (~130 ms of elasticity at 250 Mbps) is **exactly** the level that should absorb bursts. The numbers make it clearer: the device reports 15800 bytes / 10 packets, so one 256-frame submission slice needs about **26 slots**, while the pool holds only **4** -- every slice was guaranteed to hit backpressure after the 4th slot and drop the remaining 200+ frames on the spot. -**修复**:`SendFrames` 返回 `SendOutcome{consumed, sent_frames, sent_bytes, skipped}`; -桥接层在 `consumed == 0` 时 `WaitForSendCapacity(50ms)` 等槽位(完成回调用条件 -变量唤醒),醒来回到循环顶部重查停机/暂停再重试,一帧不丢。 +**Fix**: `SendFrames` returns `SendOutcome{consumed, sent_frames, sent_bytes, skipped}`; when `consumed == 0` the bridge calls `WaitForSendCapacity(50ms)` to wait for a slot (the completion callback wakes a condition variable), then returns to the top of the loop to recheck shutdown/pause and retry, dropping nothing. -| 指标 | 修复前 | 修复后 | +| Metric | Before fix | After fix | |---|---:|---:| -| TetherKit 自己的 TX 丢帧 | 1889 | **0** | -| TCP TX 重传 | 3829 | **0** | -| 双向并发 TX | 4.8 Mbps | **87 Mbps** | -| 单向 TCP RX(回归) | 324~327 | 326~327 | +| TetherKit's own TX frame drops | 1889 | **0** | +| TCP TX retransmits | 3829 | **0** | +| Bidirectional concurrent TX | 4.8 Mbps | **87 Mbps** | +| Unidirectional TCP RX (regression check) | 324-327 | 326-327 | -两点诚实说明:双向 RX 从 320 降到 240 —— 那不是退步而是公平性恢复(修复前 TX -被饿死,RX 独占链路;合计仍是约 327 Mbps)。另外原先归因于「bufferbloat」的 -双向塌陷,其实 bufferbloat 只是次要因素:RTT 膨胀依然存在,但把丢帧修掉后 -TX 就从 4.8 回到了 87 Mbps。 +Two honest notes: bidirectional RX dropped from 320 to 240 -- that is not a regression but a restoration of fairness (before the fix TX was starved and RX monopolized the link; the total is still ~327 Mbps). Also, the bidirectional collapse previously attributed to "bufferbloat" was mostly not bufferbloat: RTT inflation still exists, but once frame drops were fixed TX recovered from 4.8 to 87 Mbps. -**顺带否掉一条旧调优建议**:修复前把 `--tx-transfers` 提到 16 能把丢帧降到约 -1/4,那是在拿池深度补偿「不肯等待」。修复后 4 / 8 / 16 三档重复三次实测**无可 -分辨差异**,默认值保持 4。 +**Also invalidated one old tuning suggestion**: before the fix, raising `--tx-transfers` to 16 could cut drops to about 1/4, but that was using pool depth to compensate for "refusing to wait". After the fix, 4 / 8 / 16 were measured three times each with **no distinguishable difference**, so the default remains 4. -⚠️ 同时修掉的两个统计错账:超长帧与短于以太头的帧原先被计入「已发出」 -(`SendFrames` 只返回一个数,注释声称调用方能推断出丢弃,实际推断不出来); -`SendFrames` 返回错误时批次剩余帧不进任何计数器,凭空消失。 +Warning: two statistics miscounts were fixed along with this: oversized frames and frames shorter than the Ethernet header were previously counted as "sent" (`SendFrames` returned only one number and the comment claimed the caller could infer drops, but it actually could not); when `SendFrames` returned an error, the rest of the batch went into no counter at all and simply vanished. --- -## 7. 已知坑与规避(**踩过的坑写在这里,不要重复踩**) - -1. **缓存行是 128 字节**,不是习惯性的 64。SPSC 队列若按 64 对齐,生产者/消费者索引仍会 - 落在同一缓存行上,false sharing 依旧存在。项目统一用 `kCacheLineSize = 128`。 -2. **`std::format` 需要部署目标 ≥ 13.3**,否则报 `to_chars unavailable`。CMake 已强制。 -3. **`std::hardware_destructive_interference_size` 在 Apple libc++ 上不存在**,别用。 -4. **BPF 没有零拷贝**,别去找 `BIOCSETZBUF`。但**有批量写** —— 见下一条。 -4b. ~~「Darwin BPF 不支持批量写」是错的~~。第 2 节最初记录的这条结论已更正: - macOS 14+ 有私有 ioctl `BIOCSBATCHWRITE`,一次 `write()` 能发多帧。 - 本项目已实现并做特性探测(`BpfLink::WriteFramesBatched`), - macOS 13 及更早自动回落到逐帧 write。 -5. **BPF `BIOCSBLEN` 必须在 `BIOCSETIF` 之前设置**,顺序错了缓冲区大小不生效。 -6. **BPF 遍历必须用 `bh_hdrlen`**,不能用 `sizeof(struct bpf_hdr)` —— 头部后面有对齐填充。 -7. **libusb 在 macOS 上没有 `detach_kernel_driver`**。若接口被内核驱动占用,只能走 - codeless kext / dext 或 re-enumerate,不能靠 libusb 解决。 -8. **别默认跑测试时有设备、有 root**,所以「跑不起来」不等于「代码错了」; - 离线可验证的部分必须全部覆盖到 mock 测试里,需要 root 的用例要能干净跳过。 -9. **CTest 的 `add_test` 里绝不能给参数加引号。** 写 - `COMMAND tetherkit_tests --test-suite="${_suite}"` 时,CMake 会把引号当作参数 - 内容原样传下去(`ctest -V` 可见传的是 `"--test-suite="foo""`),doctest 匹配不到 - 任何用例 → 跑 0 个用例 → doctest 对零匹配返回退出码 0 → ctest 报 Passed。 - **整套测试静默失效却全绿**,实际掩盖了 4 个真实失败。 - 正确写法是 `--test-suite=${_suite}`(无引号)。 -10. **给 ctest 用例加「至少跑到一条断言」的保险时,必须用 - `FAIL_REGULAR_EXPRESSION` 而不是 `PASS_REGULAR_EXPRESSION`。** 后者会 - **取代**退出码判定(CMake 的文档行为),一旦设上,真实的断言失败反而被忽略 —— - 等于把上一条刚修好的坑又挖回来。`FAIL_` 是叠加在退出码之上的。 -11. 时间相关的类**不要在构造函数里自己读时钟**。`PeriodicTimer` 原来那样写, - 导致构造函数读到的时刻比调用方手里的 `now` 略晚,「now + period」反而还没到期, - 单元测试随机失败且不可复现。改成显式传入计时起点(默认值保留便利写法)。 -12. **⚠️ macOS 上的同步 libusb 中断传输会永久阻塞,timeout 参数根本不被遵守。** - 一旦踩上,症状是控制线程卡死、统计不再输出、连 SIGTERM 都响应不了、 - 退出时 feth 接口残留。栈长这样: +## 7. Known pitfalls and mitigations (**write pitfalls here when you hit them; do not hit them twice**) + +1. **The cache line is 128 bytes**, not the habitual 64. If an SPSC queue is aligned to 64, the producer and consumer indices still land on the same cache line and false sharing remains. The project uses `kCacheLineSize = 128` everywhere. +2. **`std::format` requires a deployment target >= 13.3**, otherwise it reports `to_chars unavailable`. CMake enforces this. +3. **`std::hardware_destructive_interference_size` does not exist in Apple libc++**; do not use it. +4. **BPF has no zero-copy**, do not look for `BIOCSETZBUF`. But **batch writes do exist** -- see the next item. +4b. ~~"Darwin BPF does not support batch writes" was wrong.~~ The conclusion originally recorded in section 2 has been corrected: macOS 14+ has the private ioctl `BIOCSBATCHWRITE`, which can send multiple frames in one `write()`. This project implements it with feature detection (`BpfLink::WriteFramesBatched`) and falls back to per-frame write automatically on macOS 13 and earlier. +5. **BPF `BIOCSBLEN` must be set before `BIOCSETIF`**; the wrong order means the buffer size does not take effect. +6. **BPF traversal must use `bh_hdrlen`**, not `sizeof(struct bpf_hdr)` -- the header is followed by alignment padding. +7. **libusb has no `detach_kernel_driver` on macOS**. If an interface is held by a kernel driver, the only options are a codeless kext / dext or re-enumeration; libusb cannot solve it. +8. **Do not assume a device or root is present when tests run**, so "it does not run" does not mean "the code is wrong"; everything verifiable offline must be covered by mock tests, and tests that need root must be able to skip cleanly. +9. **Never put quotes around arguments in ctest's `add_test`.** Writing `COMMAND tetherkit_tests --test-suite="${_suite}"` causes CMake to pass the quotes as part of the argument content (`ctest -V` shows `"--test-suite="foo""`), doctest matches no cases -> 0 cases run -> doctest returns exit code 0 for zero matches -> ctest reports Passed. **The entire test suite silently became a no-op while staying all green**, actually hiding 4 real failures. The correct form is `--test-suite=${_suite}` (no quotes). +10. **When adding a "must hit at least one assertion" guard to a ctest case, use `FAIL_REGULAR_EXPRESSION`, not `PASS_REGULAR_EXPRESSION`.** The latter **replaces** exit-code evaluation (CMake's documented behavior); once set, a real assertion failure is ignored instead -- digging the previous pitfall right back open. `FAIL_` is additive on top of the exit code. +11. **Time-related classes must not read the clock in their constructor.** `PeriodicTimer` used to do that, which meant the instant read in the constructor was slightly later than the caller's `now`, so "now + period" had not yet elapsed and unit tests failed randomly and non-reproducibly. Changed to take the timing origin explicitly (the default value keeps the convenient form). +12. **Warning: synchronous libusb interrupt transfers block forever on macOS; the timeout parameter is not honored at all.** Once hit, the symptoms are a stuck control thread, no more statistics output, no response even to SIGTERM, and leftover feth interfaces on exit. The stack looks like this: ``` - Poll → WaitForNotification → do_sync_bulk_transfer - → sync_transfer_wait_for_completion → handle_events → poll(∞) + Poll -> WaitForNotification -> do_sync_bulk_transfer + -> sync_transfer_wait_for_completion -> handle_events -> poll(infinity) ``` - 原因链两层(均可在 libusb 源码里核对):① darwin 后端给所有 transfer 打 - `USBI_TRANSFER_OS_HANDLES_TIMEOUT`,于是 `libusb_get_next_timeout` 跳过它们、 - 返回「无超时」,同步等待里的 `poll()` 无限期阻塞;② 而 IOKit 侧 darwin 的 - `submit_interrupt_transfer` 用的是 **`ReadPipeAsync`(无超时变体)**, - 不是 bulk 用的 `ReadPipeAsyncTO` —— 所以中断传输的 timeout **压根不生效**。 - **把 timeout 从 0 改成 1 ms 并不能解决**:第 ② 条决定了它对中断端点自始至终无效。 - 唯一正确解法:提交常驻的**异步**中断传输,让它在事件线程上完成, - `WaitForNotification` 只查一个原子标志,永不进入 libusb 的等待路径。 - → 已如此实现,见 `UsbControlChannel::StartNotificationListener`。 -13. 顺带记住:`timeout = 0` 在 libusb/darwin 上表示**无限等待**,不是「不等待」。 - 想「只探一下」要用非零的最小值(`kProbeOnlyTimeoutMillis`), - 而且如上所述对中断端点连这个都不管用。 -14. **不设 `BIOCSHDRCMPLT = 1` 的话,往 feth 上的 BPF `write()` 直接返回 `ENXIO` - ("Device not configured"),根本不是「帧头被改写」这么轻。** 做过对照实验: - 同一段代码、同一个接口,唯一差别就是设不设这个 ioctl —— 不设必 `ENXIO`, - 设了 `write` 立刻成功。 - `bpf_link.cc` 里原来的注释只说了 `hdrcmplt=0` 会「剥掉前 14 字节重建帧头」, - 那是 `hdrcmplt` 的**语义**;在 feth 上的**实际后果**是写操作压根不成立 - (`if_fake` 的输出路径处理不了 `AF_UNSPEC` 那条重建分支)。 - 排查 `ENXIO` 时很容易误以为是接口没起来或名字写错 —— 先查这个 ioctl。 - 顺序上它必须在 `BIOCSETIF` 之前,且批量写(`BIOCSBATCHWRITE`)硬性要求它是 1。 - -15. **⚠️ `osascript ... with administrator privileges` 会吞掉进程间的 SIGTERM 投递。** - 用它做提权测试时,脚本里 `kill -TERM $pid` 返回 0、目标进程的 `blocked` 与 - `pending` 掩码都是 0、`sigaction` 装的 handler 也确实在位(`sample` 能看到 - handler 地址不是 `SIG_IGN`),但**信号永远不会到达**,进程只能 `kill -9`。 - 用一个 15 行的 C 程序做过对照:普通 shell ✅、普通 `osascript` ✅、 - 加 `with administrator privileges` ❌。 - → 后果:**别用这条提权链去验证优雅停机**,会得到「停机卡死」的假故障, - 而且它跟被测代码毫无关系(修复前后的运行日志里都是清一色的 `Killed: 9`, - 一度被误当成新引入的回归)。要验证端到端停机,得在真正的终端里 - `sudo build/bin/tetherkit-cli` 再 Ctrl-C —— 这样验证过,正常打出「已停机」 - 且 feth 无残留。 - -16. **「刻意这样做」的注释也可能是错的 —— 尤其是当它读起来很有说服力时。** - TX 丢帧的根因(第 6.5 节)藏了整整一个版本,就因为那段丢弃逻辑配了一条 - 自信的注释:「等待会让内核缓冲堆积成看不见的丢包」。两个前提都不成立: - `bs_drop` 每批都被读回来并显示在统计行里;而 4 MiB 内核缓冲正是该吸收突发 - 的那一级。 - → 教训:**写「刻意」时要能说出量级**。这里只要算一下「一个 256 帧的分片需要 - 约 26 个传输槽,而池里只有 4 个」就会立刻发现不对。审查时看到 - 「刻意 / deliberately / on purpose」,先去核它的数量级假设。 - -17. **「尽力而为」的字段,空值不能覆盖已知的好值。** 连接后设备名退化成 - VID:PID 的缺陷是三层叠加:字符串描述符在设备被占用时读不到(空串)、 - GUI 在「已点连接但状态还没轮询到 running」的窗口里恰好刷了一次设备列表 - (空串覆盖好数据)、running 后列表冻结(退化名固化到会话结束)。 - 修复不去堵竞态窗口 —— XPC 往返之间天然有间隙,堵不完 —— 而是让空串不再 - 出现:`tk_list_devices` 按「总线 + 地址 + VID:PID」记住上次成功读到的 - 字符串,读不到就回填,设备离场即清除记忆(防止旧名字串到接替同一地址的 - 新设备上)。**「这次拿不到」≠「设备没有名字」**;一切 best-effort 字段 - 同理 —— 消费方拿旧值兜底或生产方回填,二选一,否则瞬时失败会固化成 - 持久的数据退化(纯逻辑在 `ReconcileDeviceStrings`,capi.support 有单测)。 - 顺带修正 helper 的占用判定:原来的「session 非 nil」两头都错 —— 握手期间 - session 还没登记(并发枚举会往握手中的控制端点插传输),failed/stopped - 的死会话早已释放设备(拔掉重插后永远读不到名字)。改按「真的持有」判定: - sessionStarting 标志 + runState ∈ {starting, running, stopping}。 -18. **SwiftUI 不知道「全局查表」变了。** 文案改成运行期查表之后,切语言不会 - 让任何 `@Observable` 属性看起来变过,视图体自然不会重算 —— 界面就那么留在 - 旧语言里。对策是让语言变化本身成为一个可观察的值(`AppModel.languageRevision` - 每次切换加一),再把它当 identity 用:主窗口与菜单栏面板都挂 - `.id(model.languageRevision)`,强制整棵树重建。 - **`.commands { }` 里更隐蔽**:里面每个 `Button` 的标签各自是独立表达式, - 实测(AppleScript 读菜单项名核对)切完语言只有语言菜单自己变了 —— 它读了 - `languagePreference` 所以有依赖 —— 旁边的「检查更新…」还是旧语言。 - 修法是把这一组包进**同一个 View**,在它的 body 里读一次 revision, - 整组就一起重算(见 `AppMenuItems`)。 - 同一类问题还有一个变种:**`static let` 存的文案只求值一次**,切语言后 - 永远不更新。凡是存文案的静态成员一律改成计算属性(`AppModel` 的三条授权 - 提示、`NetworkCard.dnsHint` 都踩过)。 -19. **macOS 26 的窗口 chrome 由 `LC_BUILD_VERSION` 的 `sdk` 决定。** 实测: - `swift build` 产物被 SwiftPM 链成 `sdk 14.0`(`-target arm64-apple-macos14.0` - 被折进 LC_BUILD_VERSION),于是 Tahoe 全机器走 **legacy chrome** —— 三灯是 - 14×16pt 按钮框、20pt 间距、内只画约 12pt 圆;对照:Hand Mirror(minos 14.4 / - sdk 26.5)与 Dropover(minos 13.0 / sdk 27.0)都是 14×14pt 按钮框、23pt 间距、 - 满格画圆。 - 所有 AppKit 窗口画的都是同一套 `_NSTheme*Widget`,差别只在 SDK 门控。 - 修复:`TetherKitApp` target 链接时加 `-platform_version macos 14.0.0 26.5.0` - (gui/Package.swift),**minos 保持 14.0,只有 chrome 受影响**。 - 以后每个发行包都核对: - `otool -l | rg -A4 LC_BUILD_VERSION` —— `sdk` 必须 ≥ 26。 - 主窗口另改为 AppKit 自管 `NSWindow`(MainWindowManager):启动即确定性呈现, - 不再依赖 SwiftUI 场景的实例化时机。三灯样式与窗口归属无关,由上面的 - `sdk` 决定。`build-gui.sh` 自检现强制执行此核对(LC_BUILD_VERSION sdk 戳 - ≥ 26),跑一次打包即验证。 -20. **`.prohibited` / dev 裸跑的窗口展示**:主窗口不再由 SwiftUI `Window` - 场景出,而是由 `@NSApplicationDelegateAdaptor` 的 delegate 在 - `applicationDidFinishLaunching` 里创建并 `makeKeyAndOrderFront` —— - 裸跑照常弹窗,不再需要菜单栏手动救回;`isWindowPresentationExpected` - 一类的把关代码全部删除(窗口何时出现由 MainWindowManager 自管,SwiftUI - 再无场景可擅自重建)。保留 `TetherKitApplication.init()` 里的 - `.prohibited → .regular` 自检:它不管窗口显现,管的是未激活进程的 - 正常激活语义。 -21. **SwiftPM 把 `sdk` 钉成 deployment target 是系统性问题。** 任何不打 - `-platform_version` 的 SwiftPM 工程在 Tahoe 上都会被当 legacy app; - Homebrew 构建同样受影响 —— 若 formula 构建漏带该 flag,用户装出来 - 仍是 legacy 三灯。 -22. **macOS 26 把 Button 动作的视图更新包进带动画的事务(按钮激活上下文)。** - 点击侧栏换页时新页短暂放大约 8-10% 再缩回,仅点击触发;定时器驱动同一状态 - 则逐像素纯净。`Transaction.disablesAnimations` 只阻止新的隐式 `.animation` - 写入事务、**禁不掉 transition** —— transition 只在事务带动画时插值 - (objc.io 的 Transaction / transitions 两篇)—— 故激活事务包住的页面写入 - 永远被插值,`.transition(.identity)` / instant 事务只让跳变变快,未根除。 - 修两处(源码注释「三重钳制」):`SidebarRow` 换 `InstantButtonStyle` - (`PrimitiveButtonStyle`):`.transaction { animation = nil }` + - `.contentShape(Rectangle)` + `.simultaneousGesture(TapGesture().onEnded - { trigger() })`,彻底不创建激活上下文(对 configuration 调 `trigger()` - 不是 `action()`);`SidebarShell.select` 把状态写入跳到下一 runloop 拍 - (`DispatchQueue.main.async`)并挂 instant 事务兜底。 - 详见 docs/GUI-ARCHITECTURE.md「页面切换为何零动画」。 - -23. **macOS 26 把「内容尺寸变化」与「窗口 frame 动画」同步**(WWDC25-256、 - release notes 61158194),而 `NSHostingView` 默认 `sizingOptions` 会把每页 - 固有尺寸推进窗口 —— 页高不同,每次换页就是一次窗口 resize 事件源。 - 修法(`MainWindowManager`):`hosting.sizingOptions = []` 必须在**附加 - contentView 之前**设置;`minSize` / `maxSize` 钉成固定尺寸。 - ⚠️ 实测 macOS 26 在窗口展示后不久会把 min/max **重置为未设**,伴随一次 - +32pt 的 chrome settle(调用栈 `_setFrameCommon:display:fromServer:`)—— - 所以 delegate 的 `windowDidBecomeMain` / `windowDidResize` 里幂等重新钉; - 幂等写法同时防自递归。窗口 `styleMask` 从不含 `.resizable`,min/max - 只用于防程序化改动。 - ⚠️ 反方向「纠偏 frame」不可行:用 `setFrame` 把 frame 从 592 纠回设计 - 尺寸 560,settle 会立刻把它再长回 592 并重置 min/max,往复循环直至 - SIGTRAP 崩溃。min/max 幂等重钉就是终态,frame 不要碰。 - -24. **macOS 26.5 把「无障碍按压轮」内排进异步队列的块全数丢弃。** - 侧栏五页在 VoiceOver 里失灵:`AXUIElementPerformAction(kAXPressAction)` - 返回 success,但 `select()` 里 `DispatchQueue.main.async` 的写块正文 - 从来不跑(`Task { @MainActor }` 同死),同轮里只有同步写存活。 - 逐项 bisect(隔离 repro)排除了 `InstantButtonStyle`(孤立复现中四种 - 按钮样式都正常)、`isSelected` trait、`onHover`、`@AppStorage` —— - 「下一 runloop 拍」这一跳本身就是死因。按压发生时 - `NSApp.currentEvent == nil` 是唯一可靠的分流信号:`select()` 现按 - 事件类型决定跳不跳拍(鼠标/键盘轮内照旧跳拍;无障碍轮直写)。键盘 - 轮内直写还有第二坑:换页重渲染会把同一按键事件重新派发,一次按键 - 双步进页 —— 故键盘轮也必须跳拍。探针无需任何权限:自进程 - `AXUIElementCreateApplication` 遍历 + `performAction` + AppStorage - 键读回即可闭环复现。真人 VO/FKA 复核(2026-11-07):方向键与 - `AXPress` 激活正常;Space/Return 确认为**真实缺陷**(真人按压同样 - 无效果,合成事件伪影的怀疑被排除)。(注:早期曾探索由侧栏容器拦截 - Space/Return 并调用 `activateFocusedRow`,该方案在 Round 11 已被完全重构为: - 行自身声明 `.accessibilityAction(.default)` 直写,窗口拦截 Return 认领 + 行级 - `.onKeyPress(.return)` 兜底换页)。已排除的两条路(勿重复施法):① 行按钮各自 - `.focusable()` 会把键盘事件拦进焦点引擎,容器的方向键处理器反而 - 收不到,方向键直接失灵;② `@FocusState` 程序化聚焦是静默 no-op, - AX 读回焦点为空,「指环随选区」走不通。另一个机器级事实:FKA - 开启后方向键被系统焦点引擎接管(「移动焦点」键),任何应用内 - 处理器都拦不住 —— 无 FKA 时容器方向键直切不受影响。实现见 `SidebarShell.rowSelect`、 - `SidebarRow` 与 `SelfAccessibility`; - 跳拍/直写的公共出口在 `ButtonTurn.write`,LogCard 的层级筛选与 - 清空按钮同用一条路(点击筛选/清空不再带激活事务的插值)。 - Round 6(三路系统级调研后定型真机激活管线):real FKA 的 Space - 根本不会以 NSEvent 到达应用 —— universalaccessd 在 HID 层的 - CGEventTap 拦截 Space 与方向键,Space 转成进程外 - `AXUIElementPerformAction` 投到焦点元素,Return 却原样通过; - 合成探针把 NSEvent 注入 `NSApp.sendEvent`,完全绕过该 tap —— - 这就是「无头全过、真机全死」的分歧根源。三条对应管线: - ① 行 AXButton 显式挂 `.accessibilityAction(.default)`(Space 的 - AXPress 落点),写入走新增的 `ButtonTurn.writeDirect` —— 进程外 - AX 轮次里 AppKit 不清 `NSApp.currentEvent`,上一把点击的残留 - 事件会骗 `write` 跳拍,而 26.5 会丢弃无障碍轮次里调度的所有 - 异步块,直写是不依赖轮次判定的唯一写法;② Return / 小键盘 - Enter 在 `SidebarMainWindow.performKeyEquivalent` 拦(AppKit 键 - 等价相位先于 firstResponder 派发,且免增加/卸载监听的生命周期 - 管理),条件是系统级 AX 焦点解析出行标题且 field editor 未激活, - 否则原样放行;③ 聚焦行自身的 `.onKeyPress(.return)` 保留为兜底(Round 11 定型; - 容器仅处理方向键)。同时 - 移除了 `model.fullKeyboardAccess` 门(AppModel 的轮询与属性一并 - 回退):`NSApp.isFullKeyboardAccessEnabled` 读的是旧偏好 - `AppleKeyboardUIMode`(Keyboard ▸ 键盘导航),不是辅助功能面板的 - FKA 开关,拿它当门会对只开后者的用户静默死路;AX 焦点匹配自身 - 就是自限的。已否/缓的路线:`.keyboardShortcut` 是窗口级全局 - 等价物,多行同挂只有第一个生效且会劫持文本输入(否); - 行 `.accessibilityRole` 重塑为 radio 组或可解锁 FKA 的 Tab - 遍历,但会动 AX 结构(按标题匹配的解析与探针都依赖它),暂不 - 做;NSEvent 本地监视器被窗口子类取代(后者免安装/卸载); - `InstantButtonStyle` 改回 `Button(configuration)` 有方向键失灵 - 回归风险(r5 已实测),不动。 - Round 7(真机 FKA-off 两处回归 + 无头探针矩阵终结):① 辅助功 - 能面板的 FKA 真值是 `CFPreferencesCopyAppValue("keyboardAccess - Enabled", "com.apple.universalaccess")`( - `SelfAccessibility.isFullKeyboardAccessOn`,每次现读);实测机 - 上 `AppleKeyboardUIMode=2`(键盘导航开)与面板开关关长期并存, - 所以 Space/Return 两条按键管线(聚焦行 `.onKeyPress` 与窗口 - `performKeyEquivalent`)都改挂这个真值为门 —— 面板开关关掉时 - 行上的 AX 焦点只是「上次浏览残留」(点行或 Tab 摸过后赖着 - 不走),裸 Space/Return 会静默重激活那一行(真机症状:哪里按 - 都跳到管理页),必须保持 inert。行的 AXPress 路径**不设门** - (VoiceOver 无关此开关)。② (探索历史 / superseded —— 现行方向键契约以 Round 11 ② 为准): - 键盘导航(`AppleKeyboardUIMode=2`)下方向键到不了 SwiftUI 容器:AppKit 键导航在 firstResponder - 派发之前吃掉它们(真机症状:先点一下侧栏方向键才活)。修在 - `SidebarMainWindow.performKeyEquivalent`(先于 firstResponder - 相位)认领裸方向键 125/126:条件是 FKA-off + first responder - 为「空」—— nil(SwiftUI 窗口常态,`NSHostingView - .acceptsFirstResponder == false` 探针实测;因此前一轮的 - `initialFirstResponder` 与 present 里的重断言全是死代码, - 已删)或本窗口/contentView 自身;文字视图(日志页与表单编辑 - 器)、分段控件等真实控件持有焦点时照旧放行。步进与 - `SidebarShell.stepSelection` 同为环绕;认领返回 handled,容器 - `.onKeyPress` 不会二次步进。③ ⚠️ 修饰键判据陷阱:方向键 - keyDown 一贯带 `.function` 位(NSModifierFlags 设备无关的硬件 - 键标记,无实体修饰键按下时也在),把 `deviceIndependentFlags - Mask` 整位掩码当和弦禁令会静默拒掉所有裸方向键(`TETHERKIT_FKA_OVERRIDE` - 覆写 + PROBE 打印证据:键进了 `performKeyEquivalent`、箭头分支却没跑);和弦 - 禁令必须是显式集合 `[.capsLock, .shift, .control, .option, - .command]`。④ 无头验证矩阵(注入 `NSApp.sendEvent`,绕不过 - 真实 HID tap,故 real-FKA-on 的 Space/方向键终判仍在真机): - UA-off 下裸下/上方向键即换页(含环绕回卷)、裸 Space/Return - 与「Tab 残留焦点 + Space/Return」全 inert、非选中行 AXPress - 照切、植入 NSTextView 与分段控件持焦时三键全放行、⌘ 和弦 - 穿透;forced-UA-on(DEBUG 覆写真值)下行激活按焦点行切 - 换、Return 在焦点行上正确认领切换、文字视图安全;合成方向键 - 在 forced-UA 下仍经容器直切属无头混成态(真实 FKA-on 下 HID - 已吃掉 NSEvent,不会发生),不是缺陷。探针脚手架中 AxProbe.swift - 已在 Round 7 验证后拆除;DEBUG fkaOverride 作为回归守卫保留。 - Round 8(焦点应答、Tab 归属与选择器收官): - ① 进程内挪 AX 焦点的唯一受支持通道是 SwiftUI 状态包装: - `.focused`/`.accessibilityFocused` 逐行绑定、 - `onChange(of: focusedRow)` 粘合(写后者发 - `kAXFocusedUIElementChanged`);从外部对自己的 - `kAXFocusedUIElement` 做 `AXUIElementSetAttributeValue` 是 - 确认过的 no-op(err=0、焦点仍 nil,探针实证)。 - ② (探索历史 / superseded):Round 8 曾尝试在轮询重渲染后通过 - `.onChange(of: model.status.monotonicNanos)` 与 involvement gate 进行 - 自读系统应答自愈。该方案在 Round 11 原生架构中已完全停用(移除相关自愈机制, - 焦点完全由 SwiftUI 原生引擎与窗口方向键自愈承载)。 - ③ 行 AXPress 由 `.accessibilityAction(.default)` 承载,探针机器证明「激励落点 = 焦点行」;真实 HID 仍属硬件 - 清单(round 7 定型的 universalaccessd 分工不变)。 - ④ segmented Picker 在键盘导航(`AppleKeyboardUIMode=2`)与 - 真 FKA 下按键无响应(r8-B),`.menu` 弹出式是键盘可操作的 - 正统替代;弹出的菜单跑 `NSEventTrackingRunLoopMode`, - `NSApp.sendEvent`/firstResponder/本地事件监视器都够不着, - 头头环境无法合成开菜单/提交 —— 菜单存在性用 CGWindowList - 窗口差分(layer>0、自 pid)取证,AXMenu 计数会被菜单栏基线 - 污染;对 `AXPopUpButton` 写 value 是幽灵成功(err=0、 - `valueSettable=false`、值不变)。 - ⑤ FKA-off 的 Tab 归属:r8 曾用 `.focusable(false)`,后在 r9/r11 重构。 - ⑥ `ButtonTurn` 移入 TetherKitUIModel(可执行目 - 标不能被 `swift test` import),激活逃逸的 commit 语义有 - 6 条单测(TetherKitUIModelTests/ButtonTurnTests)。 - Round 9(真机复检推翻 r8 的 FKA-off Tab 契约): - ① 键盘导航下 Tab 循环原不打进侧栏(行 `.focusable(false)`), - 只要任何控件持有焦点,窗口方向键认领的「焦点为空/窗口自身」前置 - 失效 → Tab 摸不到侧栏且方向键搁浅(用户真机)。契约反转:行 - `.focusable(true)` 改常量 —— r8 探针矩阵的期望项「Tab 永不落行」 - 从此作废,未来探针矩阵先改期望;FKA-off 的 inert 不再靠修饰链、 - 改靠激活动作分流:FKA-off 门内按 `NSApp.currentEvent` 类型区分, - 键盘驱动的原生激活(现场 keyDown)丢弃、鼠标事件放行。⚠️ - `currentEvent` 的读取有两处(此门与 `ButtonTurn.write` 的跳拍 - 判定),均只作否定/分流信号 —— AXPress 轮会残留 - 旧事件(本坑 round 6),别拿它做正向判据。 - ② Return 真机失败补记:窗口 `performKeyEquivalent` 认领的 AX - 自读在合成探针下能解析出行,用户真机 FKA 下解析不出 → Return - 静默穿透。补聚焦行自身的 `.onKeyPress(.return)`(FKA-on 门, - 直调 `rowSelect`),零 AX-read 依赖 —— 无头环境绕不开 HID 层, - 这条链在真机断在哪一环只有真机能验;两级认领先到先消费,重复最 - 坏是幂等换页。小键盘 Enter(keycode 76)仍只有窗口认领覆盖 - (SwiftUI `KeyPress.Key` 无小键盘变体)。 - Round 10(自驱键盘环探索,已在 Round 11 废弃 / superseded): - ⚠️ 注意:Round 10 曾探索在 AppKit 窗口层认领裸 Tab、在 - `AppModel.sidebarRingRow` 上维护自驱键盘环,并用 `SidebarFocusBridge` - 供探针取样。该设计在 Round 11 证实为过度设计且阻断正常焦序,已被完全移除, - 代码中已无 `sidebarRingRow` 等残留,勿再引入。 - Round 11(最终架构:原生焦点循环贯通与方向键全态活化): - ① 根因查明:此前探针未进侧栏的死穴是 `rows` 容器上绑定的 - `.focusable(SelfAccessibility.isFullKeyboardAccessOn)` - (FKA-off 下将整组判定为不可聚焦);容器去 focusable、仅选中行 - 在 FKA-off 下声明 `.focusable(isSelected)`(FKA-on 下各行均可聚焦)。 - Tab 键直接形成「侧栏选中行 ↔ 内容各按钮」的双向原生 Tab 循环。 - ② 方向键活化:`handleArrowKey` 守卫改为仅在 `isFirstResponderEditableText` - 为真时放行文本编辑(移动光标),按钮/卡片/滚动区获焦时方向键随时直步换页, - 换页自愈焦点,用户绝不被卡在当前页。容器 `.onKeyPress` 保留上下方向键作为兜底。 - FKA-on 时方向键由系统焦点引擎接管(HID 层的「移动焦点」键),窗口认领 - 与容器兜底都收不到事件,方向键移动的是 key 焦点本身 —— 预期行为,不是缺陷。 - ③ Return 认领与行级后背:窗口 `performKeyEquivalent` 在第一响应者非 field editor 时, - 尝试自进程系统级 AX 焦点解析(需 TCC 信任);若未信任或未解析出行,事件穿透至聚焦行自身 - `.onKeyPress(.return)` 兜底换页。 - ④ 焦点显式高亮:行获焦(任意行)且窗口处于活跃态(`controlActiveState == .key`)时展示 accent - 描边与浅灰底,窗口失焦切至后台时描边自动隐去。选区跟随方向键同步移动。 + Two layers in the cause chain (both verifiable in libusb source): (1) the darwin backend marks all transfers with `USBI_TRANSFER_OS_HANDLES_TIMEOUT`, so `libusb_get_next_timeout` skips them and returns "no timeout", and the `poll()` in the synchronous wait blocks indefinitely; (2) on the IOKit side, darwin's `submit_interrupt_transfer` uses **`ReadPipeAsync` (the no-timeout variant)**, not `ReadPipeAsyncTO` used by bulk -- so the interrupt transfer's timeout **never takes effect at all**. **Changing timeout from 0 to 1 ms does not fix it**: layer (2) means it is ineffective for interrupt endpoints from the start. The only correct fix: submit a standing **asynchronous** interrupt transfer, let it complete on the event thread, and have `WaitForNotification` only check an atomic flag so it never enters libusb's wait path. -> Implemented this way; see `UsbControlChannel::StartNotificationListener`. +13. Also remember: `timeout = 0` means **wait forever** in libusb/darwin, not "do not wait". To "just probe", use a non-zero minimum value (`kProbeOnlyTimeoutMillis`), and as noted above this still does not work for interrupt endpoints. +14. **Without setting `BIOCSHDRCMPLT = 1`, a BPF `write()` to feth returns `ENXIO` ("Device not configured") directly -- it is not as mild as "the frame header gets rewritten".** A controlled experiment confirmed this: same code, same interface, the only difference was whether this ioctl was set -- without it `ENXIO` every time, with it `write` succeeds immediately. The original comment in `bpf_link.cc` only said `hdrcmplt=0` would "strip the first 14 bytes and rebuild the frame header"; that is the **semantics** of `hdrcmplt`. The **actual consequence** on feth is that the write does not work at all (`if_fake`'s output path cannot handle the `AF_UNSPEC` rebuild branch). When debugging `ENXIO` it is easy to assume the interface is not up or the name is wrong -- check this ioctl first. In ordering it must come before `BIOCSETIF`, and batch write (`BIOCSBATCHWRITE`) requires it to be 1. + +15. **Warning: `osascript ... with administrator privileges` swallows inter-process SIGTERM delivery.** When using it for elevation testing, `kill -TERM $pid` inside the script returns 0, the target process's `blocked` and `pending` masks are both 0, and the handler installed by `sigaction` is present (sample shows the handler address is not `SIG_IGN`), yet **the signal never arrives** and the process can only be `kill -9`'d. A 15-line C program served as a control: plain shell OK, plain `osascript` OK, add `with administrator privileges` fails. -> Consequence: **do not use this elevation path to verify graceful shutdown**; it produces a false "shutdown hangs" failure that has nothing to do with the code under test (the run logs before and after the fix were all `Killed: 9`, which was once mistaken for a newly introduced regression). To verify end-to-end shutdown, run `sudo build/bin/tetherkit-cli` in a real terminal and press Ctrl-C -- that has been verified to print "stopped" with no feth residue. + +16. **A "deliberately done this way" comment can still be wrong -- especially when it sounds convincing.** The root cause of TX frame drops (section 6.5) hid for an entire release because that drop logic was accompanied by a confident comment: "waiting would let the kernel buffer pile up into invisible drops". Both premises were wrong: `bs_drop` is read back and shown in the statistics line on every batch; and the 4 MiB kernel buffer is exactly the level that should absorb bursts. -> Lesson: **when writing "deliberate", be able to state the magnitude**. Here, a quick calculation of "one 256-frame slice needs about 26 transfer slots while the pool has only 4" would have made the problem obvious immediately. When reviewing "deliberately / on purpose", first check its quantitative assumptions. + +17. **For "best-effort" fields, an empty value must not overwrite a known good one.** The defect where the device name degraded to VID:PID after connecting was three layers deep: string descriptors cannot be read while the device is occupied (empty string), the GUI happened to refresh the device list once in the window between "connect clicked" and "status polled to running" (empty string overwrote good data), and the list froze after running (the degraded name was fixed for the rest of the session). The fix does not try to block the race window -- there is always a gap across an XPC round trip, and it cannot be blocked completely -- instead it makes empty strings stop appearing: `tk_list_devices` remembers the last successfully read strings for each device keyed by "bus + address + VID:PID" and backfills when a read comes up empty; when the device leaves, the memory is cleared (to prevent an old name from attaching to a new device that takes the same address). **"Could not get it this time" is not the same as "the device has no name"**; the same applies to all best-effort fields -- either the consumer falls back to the old value or the producer backfills, pick one, otherwise a transient failure becomes a persistent data degradation (pure logic lives in `ReconcileDeviceStrings`, with a unit test in capi.support). The helper's occupancy check was fixed along with this: the old "session is non-nil" rule was wrong in both directions -- during handshake the session is not registered yet (a concurrent enumeration would insert transfers into the control endpoint mid-handshake), while a failed/stopped dead session has already released the device (after unplug/replug the name could never be read again). Changed to "actually holds it": the sessionStarting flag + runState in {starting, running, stopping}. +18. **SwiftUI does not know that "a global table lookup" has changed.** After moving strings to runtime lookup, changing the language does not make any `@Observable` property appear to change, so view bodies naturally do not recompute -- the UI just stays in the old language. The fix is to make the language change itself an observable value (`AppModel.languageRevision` increments on every switch) and use it as an identity: the main window and the menu-bar panel both attach `.id(model.languageRevision)` to force the entire tree to rebuild. **`.commands { }` is more subtle**: every `Button` label inside is its own independent expression, and in practice (verified by reading menu item names via AppleScript) after switching language only the language menu itself changed -- because it read `languagePreference` and therefore had a dependency -- while the neighboring "Check for Updates…" stayed in the old language. The fix is to wrap that group in **one View** and read revision once in its body so the whole group recomputes together (see `AppMenuItems`). A variant of the same problem: **a `static let` that stores text is evaluated only once** and never updates after a language switch. Any static member that stores text must be a computed property (the three authorization hints on `AppModel` and `NetworkCard.dnsHint` both hit this). +19. **On macOS 26, window chrome is determined by `LC_BUILD_VERSION`'s `sdk`.** Measured: a `swift build` product was linked by SwiftPM as `sdk 14.0` (`-target arm64-apple-macos14.0` folded into LC_BUILD_VERSION), so on an all-Tahoe machine it took **legacy chrome** -- the traffic lights were 14x16 pt button frames, 20 pt spacing, drawing only ~12 pt circles inside; by contrast Hand Mirror (minos 14.4 / sdk 26.5) and Dropover (minos 13.0 / sdk 27.0) both use 14x14 pt button frames, 23 pt spacing, full-size circles. All AppKit windows draw the same `_NSTheme*Widget` set; the difference is only SDK-gated. Fix: add `-platform_version macos 14.0.0 26.5.0` when linking the `TetherKitApp` target (gui/Package.swift), **keeping minos at 14.0 so only chrome is affected**. Every release package must be checked: `otool -l | rg -A4 LC_BUILD_VERSION` -- `sdk` must be >= 26. The main window was also changed to an AppKit-managed `NSWindow` (MainWindowManager): it is presented deterministically at startup and no longer depends on SwiftUI scene instantiation timing. Traffic-light style is independent of window ownership and is determined by the `sdk` above. `build-gui.sh`'s self-check now enforces this verification (LC_BUILD_VERSION sdk stamp >= 26); one packaging run verifies it. +20. **Window presentation for `.prohibited` / dev runs**: the main window no longer comes from a SwiftUI `Window` scene but is created and `makeKeyAndOrderFront`'d by the delegate in `applicationDidFinishLaunching` via `@NSApplicationDelegateAdaptor` -- a bare run still pops the window and no longer needs manual rescue from the menu bar; gatekeeping code such as `isWindowPresentationExpected` was removed entirely (window presentation is managed by MainWindowManager, and SwiftUI has no scene left to recreate on its own). The `.prohibited -> .regular` self-check in `TetherKitApplication.init()` is kept: it does not control window presentation, it controls normal activation semantics for an unactivated process. +21. **SwiftPM pinning `sdk` to the deployment target is a systemic issue.** Any SwiftPM project built without `-platform_version` will be treated as a legacy app on Tahoe; Homebrew builds are affected the same way -- if a formula build omits that flag, users still get legacy traffic lights. +22. **macOS 26 wraps view updates from Button actions in an animated transaction (button activation context).** Clicking the sidebar to switch pages made the new page briefly scale up by about 8-10% and then settle, triggered only by clicks; the same state change driven by a timer was pixel-clean. `Transaction.disablesAnimations` only prevents new implicit `.animation` writes into the transaction and **does not suppress transitions** -- a transition interpolates only when the transaction is animated (objc.io's Transaction / transitions posts) -- so a page write wrapped in an activation transaction is always interpolated, and `.transition(.identity)` / an instant transaction only make the jump faster without removing the root cause. Fixed in two places (source comment "triple clamp"): `SidebarRow` switches to `InstantButtonStyle` (`PrimitiveButtonStyle`): `.transaction { animation = nil }` + `.contentShape(Rectangle)` + `.simultaneousGesture(TapGesture().onEnded { trigger() })`, so no activation context is created at all (call `trigger()` on the configuration, not `action()`); `SidebarShell.select` moves the state write to the next runloop tick (`DispatchQueue.main.async`) with an instant-transaction backstop. See "Why page switches have zero animation" in docs/GUI-ARCHITECTURE.md. + +23. **macOS 26 synchronizes "content size changes" with "window frame animation"** (WWDC25-256, release notes 61158194), and the default `sizingOptions` on `NSHostingView` push each page's intrinsic size into the window -- different page heights mean every page switch becomes a window-resize event source. Fix (`MainWindowManager`): `hosting.sizingOptions = []` must be set **before** attaching the contentView; `minSize` / `maxSize` are pinned to fixed values. Warning: on macOS 26, min/max are **reset to unset** shortly after the window is shown, accompanied by a +32 pt chrome settle (call stack `_setFrameCommon:display:fromServer:`) -- so the delegate idempotently re-pins them in `windowDidBecomeMain` / `windowDidResize`; the idempotent write also prevents self-recursion. The window `styleMask` never includes `.resizable`; min/max are used only to prevent programmatic changes. Warning: the reverse "correct the frame" approach does not work: calling `setFrame` to correct the frame from 592 back to the designed size of 560 is immediately undone by the settle, which stretches it back to 592 and resets min/max, looping until SIGTRAP. Idempotent min/max re-pinning is the end state; do not touch the frame. + +24. **macOS 26.5 discards every block scheduled onto an async queue inside an "accessibility press turn".** + The five-page sidebar broke under VoiceOver: `AXUIElementPerformAction(kAXPressAction)` returned success, but the body of the block written inside `select()` via `DispatchQueue.main.async` never ran (`Task { @MainActor }` died the same way), and only synchronous writes in the same turn survived. Bisection (isolated repro) ruled out `InstantButtonStyle` (all four button styles worked fine in the isolated repro), the `isSelected` trait, `onHover`, and `@AppStorage` -- the "jump to the next runloop tick" itself was the cause. `NSApp.currentEvent == nil` at press time is the only reliable branching signal: `select()` now decides whether to defer based on event type (defer on mouse/keyboard turns as before; write directly on accessibility turns). Direct writes on keyboard turns have a second pitfall: re-rendering the page after the switch can re-dispatch the same key event, stepping twice on one key press -- so keyboard turns must also defer. The probe needs no permissions: a self-process `AXUIElementCreateApplication` traversal + `performAction` + AppStorage key readback can reproduce the loop. Live VO/FKA recheck (2026-11-07): arrow keys and `AXPress` activation work; Space/Return were confirmed as **real defects** (live presses had the same effect, ruling out synthetic-event artifacts). (Note: an early attempt had the sidebar container intercept Space/Return and call `activateFocusedRow`; that approach was completely reworked in Round 11: the row itself declares `.accessibilityAction(.default)` with a direct write, the window intercepts Return to claim it + row-level `.onKeyPress(.return)` as backstop.) Two rejected approaches (do not repeat): (1) giving each row button its own `.focusable()` traps keyboard events in the focus engine so the container's arrow-key handler never receives them and arrow keys stop working entirely; (2) programmatic focus via `@FocusState` is a silent no-op, and the AX readback shows no focus, so "ring follows selection" cannot be done that way. Another machine-level fact: with FKA on, arrow keys are taken over by the system focus engine (the "Move Focus" keys) and no in-app handler can intercept them -- without FKA the container arrow-key direct switch is unaffected. Implementation in `SidebarShell.rowSelect`, `SidebarRow`, and `SelfAccessibility`; the shared write/deferral exit is in `ButtonTurn.write`, and the LogCard level filter and clear button use the same path (clicking filter/clear no longer interpolates with an activation transaction). + Round 6 (after three-way system-level investigation, the live activation pipeline was finalized): under real FKA, Space never reaches the app as an NSEvent -- universalaccessd intercepts Space and arrow keys in the HID layer via a CGEventTap, converts Space into an out-of-process `AXUIElementPerformAction` delivered to the focused element, while Return passes through unchanged; the synthetic probe injected NSEvents into `NSApp.sendEvent`, bypassing that tap entirely -- this is the root of the "all pass headless, all fail live" divergence. Three corresponding pipelines: (1) the row AXButton explicitly attaches `.accessibilityAction(.default)` (the AXPress landing point for Space), with the write taking the new `ButtonTurn.writeDirect` path -- in an out-of-process AX turn AppKit does not clear `NSApp.currentEvent`, so a stale event left over from the previous click can trick `write` into deferring, and 26.5 discards every async block scheduled inside an accessibility turn, making a direct write the only path that does not depend on turn detection; (2) Return / numpad Enter are intercepted in `SidebarMainWindow.performKeyEquivalent` (AppKit's key-equivalent phase runs before firstResponder dispatch and avoids installing/uninstalling a listener lifecycle), conditioned on the system-level AX focus resolving to a row title and the field editor not being active, otherwise passed through; (3) the focused row's own `.onKeyPress(.return)` is kept as a backstop (Round 11 finalization; the container handles only arrow keys). At the same time the `model.fullKeyboardAccess` gate was removed (AppModel's polling and property rolled back with it): `NSApp.isFullKeyboardAccessEnabled` reads the old preference `AppleKeyboardUIMode` (Keyboard > Keyboard navigation), not the FKA toggle in the Accessibility panel, so using it as a gate silently dead-ends users who have only the latter on; matching AX focus to itself is self-limiting. Rejected/deferred approaches: `.keyboardShortcut` is a window-global equivalent -- attaching it to multiple rows means only the first takes effect and it hijacks text input (rejected); reshaping the row `.accessibilityRole` into a radio group or unlocking FKA Tab traversal would change the AX structure (both the title-matching resolver and the probes depend on it), deferred for now; the NSEvent local monitor was replaced by the window subclass (the latter avoids install/uninstall); changing `InstantButtonStyle` back to `Button(configuration)` risks regressing arrow keys (measured in r5), left as-is. + Round 7 (two live FKA-off regressions + headless probe matrix finalized): (1) the true FKA value in the Accessibility panel is `CFPreferencesCopyAppValue("keyboardAccess Enabled", "com.apple.universalaccess")` (`SelfAccessibility.isFullKeyboardAccessOn`, read fresh each time); on the test machine `AppleKeyboardUIMode=2` (keyboard navigation on) and the panel toggle off coexisted for long periods, so both Space/Return key pipelines (the focused row's `.onKeyPress` and the window's `performKeyEquivalent`) are now gated on this true value -- when the panel toggle is off, AX focus on a row is just "residual from the last browse" (it lingers after clicking a row or touching it with Tab), and a bare Space/Return would silently re-activate that row (live symptom: pressing anywhere jumps to the Management page), so it must stay inert. The row's AXPress path has **no gate** (VoiceOver is independent of this toggle). (2) (exploration history / superseded -- the current arrow-key contract is Round 11 (2)): under keyboard navigation (`AppleKeyboardUIMode=2`), arrow keys did not reach the SwiftUI container because AppKit key navigation consumed them before firstResponder dispatch (live symptom: arrow keys only worked after clicking the sidebar once). Fixed by claiming bare arrow keys 125/126 in `SidebarMainWindow.performKeyEquivalent` (before the firstResponder phase), conditioned on FKA-off + first responder being "empty" -- nil (the normal SwiftUI window case; probe measured `NSHostingView.acceptsFirstResponder == false`, so the previous round's `initialFirstResponder` and the reassert in present were dead code and were removed) or the window/contentView itself; when a text view (log page and form editors), segmented control, or other real control holds focus, pass through as usual. Stepping matches `SidebarShell.stepSelection` and wraps; a claimed return is handled, so the container `.onKeyPress` does not step twice. (3) Warning -- modifier-flag trap: arrow-key keyDown events always carry the `.function` bit (NSModifierFlags' device-independent hardware-key marker, present even with no physical modifier held); treating the entire `deviceIndependentFlagsMask` as a chord prohibition silently rejects every bare arrow key (`TETHERKIT_FKA_OVERRIDE` override + PROBE print evidence: the key entered `performKeyEquivalent` but the arrow branch never ran); the chord prohibition must be the explicit set `[.capsLock, .shift, .control, .option, .command]`. (4) Headless verification matrix (injecting into `NSApp.sendEvent`, which cannot bypass the real HID tap, so the final verdict on real-FKA-on Space/arrow keys remains on live hardware): with UA off, bare down/up arrow keys switch pages immediately (including wrap-around), bare Space/Return and "Tab-residual focus + Space/Return" are all inert, AXPress on a non-selected row switches correctly, all three keys pass through when an inserted NSTextView or segmented control holds focus, and Command chords pass through; with forced UA-on (DEBUG override of the true value), row activation switches to the focused row, Return correctly claims and switches on the focused row, and text views are safe; synthetic arrow keys still switching directly through the container under forced UA is a headless hybrid state (under real FKA-on the HID layer has already eaten the NSEvent and it does not happen), not a defect. The probe scaffolding AxProbe.swift was removed after Round 7 verification; the DEBUG fkaOverride is kept as a regression guard. + Round 8 (focus acknowledgement, Tab ownership, and picker wrap-up): (1) the only supported in-process channel for moving AX focus is SwiftUI state wrappers: `.focused` / `.accessibilityFocused` bound per row, glued with `onChange(of: focusedRow)` (writing the latter emits `kAXFocusedUIElementChanged`); calling `AXUIElementSetAttributeValue` on your own `kAXFocusedUIElement` from the outside is a confirmed no-op (err=0, focus still nil, measured by probe). (2) (exploration history / superseded): Round 8 attempted to self-heal by re-reading the system answer after poll-driven re-renders via `.onChange(of: model.status.monotonicNanos)` and an involvement gate. That approach was fully retired in the Round 11 native architecture (the self-heal mechanisms were removed; focus is now carried entirely by SwiftUI's native engine and window arrow-key self-healing). (3) the row AXPress is carried by `.accessibilityAction(.default)`; the probe machine proved "stimulus lands on = focused row"; the real HID path remains on the hardware checklist (the universalaccessd division of labor finalized in round 7 is unchanged). (4) a segmented Picker does not respond to keys under keyboard navigation (`AppleKeyboardUIMode=2`) or real FKA (r8-B); the `.menu` pop-up style is the keyboard-operable orthodox replacement; the popped menu runs in `NSEventTrackingRunLoopMode`, so `NSApp.sendEvent` / firstResponder / local event monitors cannot reach it and a headless environment cannot synthesize open-menu/submit -- menu existence was evidenced by a CGWindowList window diff (layer>0, own pid), AXMenu counts are polluted by the menu-bar baseline; writing a value to `AXPopUpButton` is a ghost success (err=0, `valueSettable=false`, value unchanged). (5) FKA-off Tab ownership: r8 used `.focusable(false)`, later reworked in r9/r11. (6) `ButtonTurn` moved into TetherKitUIModel (executable targets cannot be imported by `swift test`), and the commit semantics of activation escape have 6 unit tests (TetherKitUIModelTests/ButtonTurnTests). + Round 9 (live recheck overturned r8's FKA-off Tab contract): (1) under keyboard navigation the Tab cycle originally did not enter the sidebar (row `.focusable(false)`), so as long as any control held focus, the "focus is empty/window itself" precondition for the window arrow-key claim failed -> Tab could not reach the sidebar and arrow keys were stranded (user live report). The contract reversed: row `.focusable(true)` became constant -- the r8 probe-matrix expectation "Tab never lands on a row" is obsolete from this point and the probe matrix must update its expectations first; FKA-off inertness no longer comes from the modifier chain but from activation-action branching: inside the FKA-off gate, native activation driven by the keyboard (live keyDown) is discarded and mouse events pass through, distinguished by `NSApp.currentEvent` type. Warning: `currentEvent` is read in two places (this gate and `ButtonTurn.write`'s defer decision), both only as a negative/branching signal -- an AXPress turn can leave a stale old event behind (this pitfall round 6), so do not use it as a positive predicate. (2) Return live-failure follow-up: the AX self-read claimed by the window's `performKeyEquivalent` could resolve a row in the synthetic probe but could not resolve one under the user's live FKA -> Return silently fell through. Added the focused row's own `.onKeyPress(.return)` (FKA-on gate, calling `rowSelect` directly), with zero AX-read dependency -- a headless environment cannot bypass the HID layer, so which link in this chain breaks on live hardware can only be verified on live hardware; the two claim levels are first-to-consume, and at worst the duplicate is an idempotent page switch. Numpad Enter (keycode 76) is still covered only by the window claim (SwiftUI `KeyPress.Key` has no numpad variant). + Round 10 (self-driven keyboard-ring exploration, superseded in Round 11): Warning: Round 10 explored claiming bare Tab at the AppKit window layer, maintaining a self-driven keyboard ring in `AppModel.sidebarRingRow`, and using `SidebarFocusBridge` for probe sampling. That design was proven over-engineered and blocked the normal focus order in Round 11 and has been completely removed; no residue such as `sidebarRingRow` remains in the code, do not reintroduce it. + Round 11 (final architecture: native focus-cycle traversal and arrow-key activation in all states): (1) Root cause identified: the dead end that kept the probe from entering the sidebar was the `.focusable(SelfAccessibility.isFullKeyboardAccessOn)` bound on the `rows` container (which marked the whole group unfocusable when FKA-off); focusable removed from the container, only the selected row declares `.focusable(isSelected)` when FKA-off (all rows focusable when FKA-on). The Tab key now forms a "selected sidebar row <-> content buttons" bidirectional native Tab cycle directly. (2) Arrow-key activation: the `handleArrowKey` guard was changed to pass through for text editing (cursor movement) only when `isFirstResponderEditableText` is true; when a button/card/scroll area has focus, arrow keys step pages directly at any time, page switches self-heal focus, and the user is never stuck on the current page. The container `.onKeyPress` keeps up/down arrow keys as a backstop. With FKA-on, arrow keys are taken over by the system focus engine (the HID-layer "Move Focus" keys), so neither the window claim nor the container backstop receives the events; arrow keys move key focus itself -- expected behavior, not a defect. (3) Return claim and row-level backstop: when the first responder is not a field editor, the window's `performKeyEquivalent` attempts an in-process system-level AX focus resolution (requires TCC trust); if untrusted or no row is resolved, the event falls through to the focused row's own `.onKeyPress(.return)` backstop to switch pages. (4) Explicit focus highlight: when any row has focus and the window is active (`controlActiveState == .key`) it shows an accent border and a light-gray background; when the window loses focus to the background the border is removed automatically. The selection follows arrow-key movement in sync. --- -## 8. 常用命令 +## 8. Common commands ```bash -# 配置 + 构建(警告当错误) +# Configure + build (warnings as errors) cmake -S . -B build -DTETHERKIT_WARNINGS_AS_ERRORS=ON && cmake --build build -j10 -# 跑测试 +# Run tests ctest --test-dir build --output-on-failure -# ThreadSanitizer 验证无锁队列(重要!) +# Verify the lock-free queue with ThreadSanitizer (important!) cmake -S . -B build-tsan -DTETHERKIT_ENABLE_TSAN=ON && cmake --build build-tsan -j10 \ && ctest --test-dir build-tsan --output-on-failure -# 性能基准(务必用未开消毒器的 Release 构建) +# Performance benchmarks (be sure to use a Release build with no sanitizers) cmake -S . -B build-rel -DCMAKE_BUILD_TYPE=Release && cmake --build build-rel -j10 \ && ./build-rel/bin/tetherkit_bench -# GUI:构建、测试、打包 +# GUI: build, test, package TETHERKIT_LIB_DIR=$PWD/build/lib swift build --package-path gui TETHERKIT_LIB_DIR=$PWD/build/lib swift test --package-path gui ./gui/Scripts/build-gui.sh -# 两种语言各看一眼(改过文案就跑一下) +# Spot-check language behavior (run after changing user-facing strings) ./build/bin/tetherkit-cli --lang en --help -./build/bin/tetherkit-cli --lang zh --list +./build/bin/tetherkit-cli --lang en --list ctest --test-dir build -R common.i18n --output-on-failure ``` diff --git a/README.md b/README.md index 25d6008..a24a680 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,6 @@ # TetherKit -**English** | [简体中文](README.zh-CN.md) - **A kext-free HoRNDIS alternative that brings Android USB tethering on macOS back to life.** TetherKit is a **user-space RNDIS driver for macOS**: it turns an RNDIS device (Android USB @@ -27,7 +25,7 @@ connect, configure IP in three clicks — plus a command-line tool, `tetherkit-c > measured against a real Android phone: **~326 Mbps RX / ~240–300 Mbps TX, zero > retransmits in both directions** (USB 2.0 high-speed; theoretical ceiling 426 Mbps). > Methodology and full results: [docs/BENCHMARKS.md](docs/BENCHMARKS.md); the -> verification checklist lives in [AGENTS.md](AGENTS.md) §6 (Chinese). +> verification checklist lives in [AGENTS.md](AGENTS.md) §6 (in English). --- @@ -172,8 +170,8 @@ blocked by Gatekeeper, so the app deliberately only checks, never swaps. To disable the automatic check: `defaults write com.tetherkit.app updateCheckDisabled -bool YES`. -**Interface language**: "TetherKit → Language" offers Follow system / 中文 / -English, and the menu bar panel carries the same switch. Changes take effect +**Interface language**: "TetherKit → Language" offers Follow system / English, +and the menu bar panel carries the same switch. Changes take effect **immediately** — no restart — and the log lines coming out of the library follow along (the language is pushed to the privileged helper too). The default is to follow the system language. @@ -215,20 +213,21 @@ Common options (see `--help` for the full list): | `--stats 1000` | Print a throughput/drop statistics line every second | | `--log debug` | Enable detailed protocol interaction logging | | `--max-transfer-kb` | The main throughput tuning knob — see [docs/PERFORMANCE.md](docs/PERFORMANCE.md) | -| `--lang zh\|en\|auto` | Interface language, default `auto` (see below) | +| `--lang en\|auto` | Interface language, default `auto` (see below) | **Language**: by default the tool checks `TETHERKIT_LANG`, `LC_ALL`, `LC_MESSAGES` and `LANG` in that order and takes the first non-empty value. -Anything starting with `zh` means Chinese; everything else means English. +English is the only language shipped today, so `en` and `auto` both select it +and any other value is rejected with an error. ```bash -tetherkit-cli --lang zh --help # pick explicitly -TETHERKIT_LANG=zh tetherkit-cli --list +tetherkit-cli --lang en --help # pick explicitly +TETHERKIT_LANG=en tetherkit-cli --list ``` > ⚠️ Whether `sudo` passes `LANG` through depends on your sudoers `env_keep`, so -> `sudo tetherkit-cli` does not necessarily follow your terminal's language — -> pass `--lang` explicitly in that case. +> `sudo tetherkit-cli` may not see the same environment as your terminal — pass +> `--lang` explicitly in that case. ### Why root is needed @@ -397,7 +396,7 @@ Aggregated results: [docs/BENCHMARKS.md](docs/BENCHMARKS.md). ## Documentation -The documents below are written in Chinese. +The documents below are written in English. | Document | Contents | |---|---| diff --git a/README.zh-CN.md b/README.zh-CN.md deleted file mode 100644 index d7be384..0000000 --- a/README.zh-CN.md +++ /dev/null @@ -1,379 +0,0 @@ -

- TetherKit 图标 -

- -# TetherKit - -[English](README.md) | **简体中文** - -**HoRNDIS 在新系统上用不了的替代方案 —— 不装内核扩展,让 Android USB 网络共享在 macOS 上重新可用。** - -TetherKit 是一个 **macOS 用户态 RNDIS 驱动**:把 RNDIS 设备(Android 手机的 USB 网络共享、 -Windows Phone、嵌入式 Linux gadget 等)变成一张系统可见的网卡,**不需要 kext、不需要 -DriverKit、不用关 SIP、不用开发者账号**。Apple Silicon 与 Intel 走同一条代码路径。 -自带图形界面,「选设备 → 连接 → 配 IP」三步点击;也提供命令行工具 `tetherkit-cli`。 - -![TetherKit 主界面](docs/assets/screenshot-main-zh.jpg) - -- **USB 侧**:[libusb](https://libusb.info/) 异步批量传输,完整实现 RNDIS 主机侧状态机。 -- **网卡侧**:macOS 的 `feth`(`if_fake`)虚拟网卡对 + BPF,直接读写原始以太帧。 -- **无内核代码**:纯用户态 C++23,不需要 kext、不需要 DriverKit、不需要关 SIP。 - -> ⚠️ **状态**:全部模块已实现,210 个测试用例(10408 条断言)在普通构建与 -> ThreadSanitizer 构建下均通过。端到端已用 Android 手机压测:**RX 约 326 Mbps / -> TX 约 240~300 Mbps,双向零重传**(USB 2.0 高速,理论上限 426 Mbps)。 -> 测量方法与完整结果见 [docs/BENCHMARKS.md](docs/BENCHMARKS.md), -> 验证清单见 [AGENTS.md](AGENTS.md) 第 6 节。 - ---- - -## 从 HoRNDIS 过来的? - -如果你是因为 **HoRNDIS 升级完 macOS 就用不了了**,或者在 Apple Silicon 上它一直显示 -「已阻止的软件」、根本找不到那个「允许」按钮 —— TetherKit 就是为这种情况写的。 - -[HoRNDIS](https://github.com/jwise/HoRNDIS) 好用了很多年,它本身没有问题。问题在于它是一个 -**内核扩展**,而这正是 macOS 一直在收紧的地方: - -| | HoRNDIS | TetherKit | -|---|---|---| -| 是什么 | 内核扩展(kext) | 普通的用户态程序 | -| Apple Silicon | 要先进恢复模式把安全策略降到「降低安全性」并重启,第三方 kext 才**有可能**加载 | 什么都不用改 —— 在系统眼里它根本不是驱动 | -| SIP | 通常需要关掉或削弱 | 不动 | -| 升级 macOS 之后 | kext 可能直接不加载,往往要重新批准或重新编译 | 就是个 App,没有什么要重新批准的 | -| 出问题的最坏情况 | 内核崩溃(kernel panic) | 进程退出 | -| 上游状态 | 最新发布 9.2(2024 年 8 月);[「MacOS Sonoma not supported」](https://github.com/jwise/HoRNDIS/issues/169) 仍未关闭 | 在积极开发 | - -TetherKit 是从另一头解决同一个问题:不去教内核说 RNDIS,而是**在用户态**通过 libusb 把 -RNDIS 说完,再借 `feth` 虚拟网卡对把以太帧交给系统。内核自始至终没有加载我们的任何代码。 - -**代价说在前面**:网卡由一个需要你启动的 App 创建(连接本身不受界面退出影响 —— 它跑在一个 -很小的特权后台组件里);吞吐受限于用户态拷贝而不是 USB 链路。不过实测下来这并没有成为瓶颈, -数据见上面。 - ---- - -## 为什么需要它 - -macOS 内核**没有** RNDIS 驱动。插上开了 USB 网络共享的 Android 手机,系统不会出现新网卡。 -既有方案要么写 kext(需要关 SIP 或降低 Apple Silicon 的安全策略、签名门槛高、系统更新易 -失效),要么走 Network Extension(需要开发者账号 + 系统扩展审批)。TetherKit 选择第三条 -路:**在用户态同时扮演 USB 主机和网卡驱动**。 - ---- - -## 安装 - -**兼容性**:macOS 14 Sonoma、15 Sequoia、26 Tahoe,Apple Silicon(M1–M4)与 Intel 都支持; -命令行工具最低到 macOS 13.3 Ventura。**什么都不用关** —— SIP 保持开启,Apple Silicon 的 -安全策略保持「完全安全性」。 - -```bash -# 图形界面 TetherKit.app(macOS 14+) -brew install justsrc/tap/tetherkit - -# 命令行工具 tetherkit-cli(macOS 13.3+) -brew install justsrc/tap/tetherkit-cli -``` - -两个 formula 都从源码构建,`libusb` 会被自动带上。tap 仓库在 -[justsrc/homebrew-tap](https://github.com/justsrc/homebrew-tap)。 - -图形界面装完这样启动(首次启动会自动在「应用程序」里建立 Finder 别名, -之后在聚焦里搜 TetherKit 即可直接启动): - -```bash -open "$(brew --prefix)/opt/tetherkit/TetherKit.app" -``` - -首次运行界面会引导安装特权组件:点「安装特权组件」、输一次管理员密码即可。 - -升级: - -```bash -brew upgrade tetherkit tetherkit-cli -``` - -> **旧版用户注意**:自 v0.1.2 起 `tetherkit` 这个 formula 名归图形界面, -> 命令行改名 `tetherkit-cli`(二进制同名)。之前装过命令行的请 -> `brew uninstall tetherkit && brew install tetherkit-cli`。 - -也可以从 [Releases](https://github.com/justsrc/TetherKit/releases) 直接下预编译产物 -(仅 arm64)。但它们**没有签名**,浏览器下载后会被 Gatekeeper 隔离,得手动解除: - -```bash -xattr -d com.apple.quarantine tetherkit-cli # 命令行 -xattr -dr com.apple.quarantine TetherKit.app # 图形界面(递归) -``` - -介意这一步的话就走上面的 Homebrew,或者按下文「[从源码构建](#从源码构建)」自己构建 -—— 本地构建的产物不带隔离属性。 - ---- - -## 图形界面 - -TetherKit.app(SwiftUI)把「选设备 → 连接 → 配 IP」做成了三步点击,实时显示吞吐与日志。 - -它由两部分组成:`TetherKit.app` 以**普通用户身份**运行,需要 root 的操作交给一个 -由 launchd 按需拉起的特权组件 `tetherkit-helper`,每次调用都附带一份用户刚确认过 -的授权凭据。App 本身不需要任何 entitlement。 - -特权组件的安装载荷内嵌在 .app 里(`Contents/Library/HelperTools/`),首次运行时 -界面会引导安装(见上文「安装」);偏好终端的话效果完全相同: - -```bash -sudo ./gui/Scripts/install-helper.sh -``` - -卸载:App 内「管理」页有「卸载特权组件…」按钮(终端等价命令 -`sudo ./gui/Scripts/uninstall-helper.sh`)。 - -界面里可以配置**上网方式**: - -| 方式 | 说明 | -|---|---| -| 自动(DHCP) | 交给系统的 IPConfiguration,租约、DNS、路由全部自动配好。绝大多数手机都自带 DHCP 服务器,推荐 | -| 静态 IP | 手动指定 IP、子网掩码、网关与 DNS。输入时即时校验(含子网掩码的连续性) | - -另有一个「让所有流量默认走这张网卡」开关。不开时只有绑定到本网卡的流量走它; -本机没有别的可用网络时通常不需要开 —— 系统会自己把它选为主服务。 - -**后台运行**:关闭主窗口后 App 驻留菜单栏(程序坞图标隐藏),菜单栏项实时 -显示上/下行速率;点开是一块小面板,可随时回到主窗口或退出。已建立的连接由 -特权组件持有,即使退出界面也不会断。 - -**检查更新**:菜单「TetherKit → 检查更新…」手动查;App 也会每天自动查一次 -(只访问 GitHub 的公开 Releases API,不上报任何信息),发现新版会在 -「管理」页点亮一条提示。更新本身仍走 `brew upgrade` 或源码重编 —— 免证书分发下 -自动替换 .app 会被 Gatekeeper 拦下,所以刻意只查不换。不想要自动检查: -`defaults write com.tetherkit.app updateCheckDisabled -bool YES`。 - -**界面语言**:菜单「TetherKit → 语言」可选「跟随系统 / 中文 / English」, -菜单栏面板里也有同一个开关。切换**立即生效**,不需要重启 App —— 库产生的 -日志行也会跟着换(语言会一并同步给特权组件)。默认跟随系统语言。 - -**要求**:macOS 14+(命令行部分仍支持 13.3+)。实现细节与设计取舍见 -[docs/GUI-ARCHITECTURE.md](docs/GUI-ARCHITECTURE.md)。 - ---- - -## 命令行工具 - -图形界面之外还有命令行工具 `tetherkit-cli`(安装见上文): - -```bash -# 先看看设备有没有被识别(**不需要 root**) -tetherkit-cli --list - -# 启动驱动 -sudo tetherkit-cli - -# 另开一个终端,给新出现的网卡配 IP(RNDIS 设备通常自带 DHCP 服务器) -sudo ipconfig set feth0 DHCP -ipconfig getifaddr feth0 -``` - -> 上面的命令都按已安装(`tetherkit-cli` 在 PATH 里)来写。从源码构建的话 -> 换成 `./build/bin/tetherkit-cli` 即可。 - -启动成功后程序会打印它创建的网卡名与后续命令。按 `Ctrl-C` 优雅退出 -(会先让设备退出 RNDIS,再销毁网卡)。 - -常用选项(完整列表见 `--help`): - -| 选项 | 说明 | -|---|---| -| `--list` | 列出识别到的 RNDIS 设备后退出,不需要 root | -| `--vid` / `--pid` | 指定设备(十六进制),用于多设备场景 | -| `--stats 1000` | 每秒打印一行吞吐/丢包统计 | -| `--log debug` | 打开协议交互细节日志 | -| `--max-transfer-kb` | 吞吐的主要调优旋钮,见 [docs/PERFORMANCE.md](docs/PERFORMANCE.md) | -| `--lang zh\|en\|auto` | 界面语言,默认 `auto`(见下) | - -**语言**:默认按 `TETHERKIT_LANG` → `LC_ALL` → `LC_MESSAGES` → `LANG` 依次推断, -取第一个非空值,以 `zh` 开头算中文、其余算英文。 - -```bash -tetherkit-cli --lang en --help # 显式指定 -TETHERKIT_LANG=en tetherkit-cli --list -``` - -> ⚠️ `sudo` 是否把 `LANG` 透传给命令取决于 sudoers 的 `env_keep`,因此 -> `sudo tetherkit-cli` 未必跟随你的终端语言 —— 那时显式写 `--lang`。 - -### 为什么需要 root - -| 操作 | 需要 root? | 原因 | -|---|---|---| -| 创建 / 销毁 `feth` | ✔ | 内核对 `SIOCIFCREATE2` / `SIOCSDRVSPEC` 有 `proc_suser` 检查 | -| 打开 `/dev/bpf*` | ✔ | 节点是 `0600 root:wheel`,且 macOS **没有** FreeBSD 的 `access_bpf` 组 | -| libusb 声明 RNDIS 接口 | ✘ | macOS 内核**没有** RNDIS 驱动,接口本来就没人占。非沙箱命令行程序不需要 root、也不需要 entitlement | - -也就是说 root 是网卡侧的要求,不是 USB 侧的。`--list` 因此不需要 root。 - ---- - -## 故障排查 - -| 现象 | 原因与对策 | -|---|---| -| `--list` 找不到设备 | ① 换一根**数据线**(很多线只供电);② 在设备上开启「USB 网络共享 / USB tethering」;③ 解锁手机并信任本机。用 `system_profiler SPUSBDataType` 确认系统是否看到该设备 | -| `声明 RNDIS 数据接口失败 [libusb: LIBUSB_ERROR_ACCESS]` | 接口被别的东西占了。检查是否装过 HoRNDIS 之类的第三方 kext,或有别的用户态程序在用该设备 | -| `创建 feth 虚拟网卡需要 root` | 用 `sudo` 运行 | -| `sysctl net.link.fake.hwcsum 当前是 1,要求 0` | 这批开关在 feth **创建时被快照**,创建后改无效。按提示先 `sudo sysctl -w net.link.fake.hwcsum=0` 再启动 | -| `ipconfig set feth0 DHCP` 拿不到地址 | 确认设备侧的网络共享真的开着;用 `--stats 1000` 看 TX 有没有帧发出去、RX 有没有回包 | -| 网卡配好了但上不了网 | 默认路由还指向原来的网卡。`sudo route -n change default $(ipconfig getoption feth0 router)`,注意这会顶掉现有默认路由 | -| 吞吐远低于预期 | 看启动日志里的「设备聚合上限 N 包」与「链路批量写」两项,再对照 [docs/PERFORMANCE.md](docs/PERFORMANCE.md) 的排查表 | -| 重启后网卡不见了 | `ipconfig set` 建立的是**临时**服务,只存活到下一次网络配置变更,且不出现在系统设置里。这是 macOS 的限制,不是 bug | - -### 常见疑问 - -**Android 的 USB 网络共享在 macOS 上到底能不能用?** -开箱即用是不能的 —— macOS 不带 RNDIS 驱动,插上开了 USB 网络共享的手机,系统里不会多出 -任何网卡。TetherKit 补的就是这一块。 - -**要关 SIP 吗?Apple Silicon 上要降安全策略吗?** -都不用。那些是**内核扩展**的要求。TetherKit 是普通程序,内核不会加载它的任何代码;需要 -特权的只是一个后台小组件,安装时输一次管理员密码,之后不再打扰。 - -**HoRNDIS 显示「已阻止的软件」、没有「允许」按钮,能修吗?** -那是 macOS 在拒绝加载第三方 kext,不是 kext 自己能解决的。Apple Silicon 上想加载它,必须 -先进恢复模式降低安全策略。TetherKit 从根上绕开了这一整类问题。 - -**M1 / M2 / M3 / M4 能用吗?** -能。Apple Silicon 与 Intel 走同一条代码路径,除了编译目标之外没有任何架构相关的东西。 - -**我的设备支持吗?** -只要它暴露 RNDIS 接口就行:多数开了 USB 网络共享的 Android 手机、Windows Phone,以及 -Linux 的 `g_ether` / `u_ether` USB gadget(树莓派 Zero、BeagleBone 等)。 -跑 `tetherkit-cli --list` 就能确认,**不需要 root**。 - -**iPhone 的 USB 共享需要它吗?** -不需要。macOS 原生支持 iPhone 的 USB 网络共享,TetherKit 面向的是它不覆盖的那些设备。 - ---- - -## 架构 - -``` - ┌──────────────────────────── macOS 内核 ────────────────────────────┐ - │ │ - │ IP 栈 / 路由 / DHCP 客户端 │ - │ │ │ - │ ▼ │ - │ ┌─────────┐ if_fake peer 对 ┌─────────┐ │ - │ │ feth0 │ ◄─────────────────► │ feth1 │ │ - │ │(系统侧) │ │(驱动侧) │ │ - │ └─────────┘ └────┬────┘ │ - │ 配 IP/路由 │ BPF │ - └────────────────────────────────────────┼───────────────────────────┘ - │ read()/write() 原始以太帧 - ┌────────────────────────────────────────┼───────────────────────────┐ - │ TetherKit(用户态) │ │ - │ ▼ │ - │ ┌──────────────────── 数据路径桥接 ─────────────────────┐ │ - │ │ TX: BPF 批量读 → 聚合多帧 → RNDIS_PACKET_MSG → bulk OUT│ │ - │ │ RX: bulk IN → 拆 RNDIS_PACKET_MSG → SPSC 队列 → BPF 写 │ │ - │ └───────────────────────────┬───────────────────────────┘ │ - │ │ │ - │ ┌──────────── RNDIS 状态机 ──┴──────────────┐ │ - │ │ INITIALIZE / QUERY / SET / KEEPALIVE / │ │ - │ │ RESET / INDICATE_STATUS / HALT │ │ - │ └───────────────────────────┬──────────────┘ │ - │ │ │ - │ ┌────────── libusb ─────────┴──────────────┐ │ - │ │ 控制通道:SEND_ENCAPSULATED_COMMAND / │ │ - │ │ GET_ENCAPSULATED_RESPONSE │ │ - │ │ 通知通道:中断 IN(RESPONSE_AVAILABLE) │ │ - │ │ 数据通道:bulk IN / bulk OUT(异步池化) │ │ - │ └───────────────────────────┬──────────────┘ │ - └───────────────────────────────┼─────────────────────────────────────┘ - │ USB - ┌────┴─────┐ - │ RNDIS 设备 │ - └──────────┘ -``` - ---- - -## 从源码构建 - -依赖:macOS 13.3+、Xcode 命令行工具(Apple clang 支持 C++23)、CMake ≥ 3.24、libusb 1.0。 - -```bash -brew install libusb cmake -``` - -```bash -cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo -cmake --build build -j -``` - -产物:`build/bin/tetherkit-cli`。 - -### 构建图形界面 - -需要完整 Xcode 工具链。在上面的构建目录基础上: - -```bash -cmake --build build --target gui # 等价于 ./gui/Scripts/build-gui.sh -open dist/TetherKit.app -``` - -### 构建选项 - -| 选项 | 默认 | 说明 | -|---|---|---| -| `TETHERKIT_BUILD_TESTS` | `ON` | 构建单元测试 | -| `TETHERKIT_BUILD_BENCHMARKS` | `ON` | 构建性能基准 | -| `TETHERKIT_WARNINGS_AS_ERRORS` | `OFF` | 警告当错误 | -| `TETHERKIT_NATIVE_ARCH` | `OFF` | `-mcpu=native`,产物不可移植 | -| `TETHERKIT_ENABLE_LTO` | `OFF` | 链接时优化 | -| `TETHERKIT_ENABLE_ASAN` | `OFF` | AddressSanitizer | -| `TETHERKIT_ENABLE_UBSAN` | `OFF` | UndefinedBehaviorSanitizer | -| `TETHERKIT_ENABLE_TSAN` | `OFF` | ThreadSanitizer(**验证无锁数据结构必备**) | - -### 测试 - -```bash -ctest --test-dir build --output-on-failure -``` - -无锁队列与多线程数据路径的正确性用 ThreadSanitizer 单独验证: - -```bash -cmake -S . -B build-tsan -DTETHERKIT_ENABLE_TSAN=ON -cmake --build build-tsan -j -ctest --test-dir build-tsan --output-on-failure -``` - -### 性能基准 - -```bash -cmake -S . -B build-rel -DCMAKE_BUILD_TYPE=Release -cmake --build build-rel -j -./build-rel/bin/tetherkit_bench -``` - -汇总结果见 [docs/BENCHMARKS.md](docs/BENCHMARKS.md)。 - ---- - -## 文档 - -| 文档 | 内容 | -|---|---| -| [docs/DESIGN.md](docs/DESIGN.md) | **总体设计**:为什么选 feth+BPF、模块划分、并发模型、拆除顺序、私有 ABI 的风险评估 | -| [docs/RNDIS-PROTOCOL.md](docs/RNDIS-PROTOCOL.md) | **协议参考**:字段偏移、状态码、OID、状态机、设备 quirk。含三条最容易搞错的规则 | -| [docs/PERFORMANCE.md](docs/PERFORMANCE.md) | **调优指南**:旋钮、怎么判断瓶颈、已知限制 | -| [docs/BENCHMARKS.md](docs/BENCHMARKS.md) | **基准结果**(自动生成)+ 测量方法与已知局限 | -| [docs/GUI-ARCHITECTURE.md](docs/GUI-ARCHITECTURE.md) | **图形界面**:进程与信任模型、数据流、实现约束、已实现/未实现清单 | -| [docs/GUI-SPIKE.md](docs/GUI-SPIKE.md) | **可行性验证**:为什么这么做特权提升,以及被排除的三条路线 | -| [AGENTS.md](AGENTS.md) | 实现备忘:已实测确认的环境事实、进度、**踩过的坑**、待验证清单 | - ---- - -## 许可 - -见 [LICENSE](LICENSE)。 diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index 335b7de..d85ea4b 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -1,398 +1,482 @@ -# TetherKit 性能基准 +# TetherKit Performance Benchmarks -由 `tetherkit_bench` 自动生成 —— TetherKit 0.1.2 (C++23, macOS 13.3+) +Automatically generated by `tetherkit_bench` -- TetherKit 0.1.2 (C++23, macOS 13.3+) -> 重新生成:`./build-rel/bin/tetherkit_bench > docs/BENCHMARKS.md` -> 必须使用 **未启用消毒器的 Release 构建**,否则数字无参考价值。 +> Regenerate: `./build-rel/bin/tetherkit_bench > docs/BENCHMARKS.md` +> Must use a **Release build without sanitizers**, otherwise the numbers are not meaningful. -## 测量环境 +## Environment -| 项目 | 值 | +| Item | Value | |---|---| | CPU | Apple M5 | -| 逻辑核 | 10(性能核 4 / 效率核 6) | -| 缓存行 | 128 字节(kCacheLineSize = 128) | -| 系统 | macOS 26.5.1 (25F80) | -| 构建 | Release, AppleClang 21.0.0.21000101, C++23, native-arch=OFF, LTO=OFF, sanitizers=none | +| Logical cores | 10 (performance 4 / efficiency 6) | +| Cache line | 128 bytes (kCacheLineSize = 128) | +| OS | macOS 26.5.1 (25F80) | +| Build | Release, AppleClang 21.0.0.21000101, C++23, native-arch=OFF, LTO=OFF, sanitizers=none | -## 基础设施层(tk_common) +## Infrastructure (tk_common) -| 分组 | 基准 | 中位数 ns/op | 最好 ns/op | 速率 | 吞吐 | 离散度 | +| Group | Benchmark | Median ns/op | Best ns/op | Rate | Throughput | Spread | |---|---|---:|---:|---:|---:|---:| -| memcpy 基线 | 1514 字节帧 | 32.91 | 32.70 | 30.38 M/s | 367.99 Gbps | 2.4% | -| | 64 字节帧 | 1.68 | 1.64 | 595.89 M/s | 305.09 Gbps | 7.4% | -| 字节序 | 解析 44 字节 RNDIS 包头(未对齐) | 1.03 | 0.99 | 972.64 M/s | — | 6.4% | -| SPSC 队列 | 单线程 push+pop | 0.33 | 0.33 | 3.01 G/s | — | 17.0% | -| | 跨线程搬运 | 5.03 | 4.70 | 198.79 M/s | — | 19.0% | -| FrameRing | 单线程往返(1514 字节) | 47.32 | 46.72 | 21.13 M/s | 255.96 Gbps | 1.9% | -| | 单线程往返(64 字节) | 3.36 | 2.95 | 297.23 M/s | 152.18 Gbps | 18.5% | -| FrameRing 批量发布 | 跨线程 1514 字节 × batch=1 | 87.00 | 84.55 | 11.49 M/s | 139.22 Gbps | 3.3% | -| | 跨线程 1514 字节 × batch=4 | 74.34 | 71.26 | 13.45 M/s | 162.93 Gbps | 11.1% | -| | 跨线程 1514 字节 × batch=8 | 72.75 | 67.75 | 13.75 M/s | 166.48 Gbps | 16.1% | -| | 跨线程 1514 字节 × batch=32 | 66.64 | 62.61 | 15.01 M/s | 181.74 Gbps | 11.5% | -| | 跨线程 1514 字节 × batch=64 | 67.90 | 64.64 | 14.73 M/s | 178.38 Gbps | 9.3% | -| | 跨线程 64 字节 × batch=1 | 15.28 | 15.01 | 65.45 M/s | 33.51 Gbps | 5.3% | -| | 跨线程 64 字节 × batch=32 | 10.69 | 10.35 | 93.56 M/s | 47.90 Gbps | 5.8% | -| 统计计数器 | relaxed load+store(本项目做法) | 0.34 | 0.33 | 2.96 G/s | — | 13.2% | -| | fetch_add 对比 | 2.04 | 1.99 | 490.26 M/s | — | 4.9% | - -> 离散度 =(最差轮 − 最好轮)/ 中位数。超过 30% 会标 ⚠,说明测量期间存在 -> 后台干扰或被调度到了效率核,此时的绝对数值仅供参考。 - -## RNDIS 编解码(tk_rndis) - -| 分组 | 基准 | 中位数 ns/op | 最好 ns/op | 速率 | 吞吐 | 离散度 | +| memcpy baseline | 1514-byte frame | 32.91 | 32.70 | 30.38 M/s | 367.99 Gbps | 2.4% | +| | 64-byte frame | 1.68 | 1.64 | 595.89 M/s | 305.09 Gbps | 7.4% | +| Byte order | Parse 44-byte RNDIS header (unaligned) | 1.03 | 0.99 | 972.64 M/s | - | 6.4% | +| SPSC queue | single-thread push+pop | 0.33 | 0.33 | 3.01 G/s | - | 17.0% | +| | cross-thread transfer | 5.03 | 4.70 | 198.79 M/s | - | 19.0% | +| FrameRing | single-thread round trip (1514 bytes) | 47.32 | 46.72 | 21.13 M/s | 255.96 Gbps | 1.9% | +| | single-thread round trip (64 bytes) | 3.36 | 2.95 | 297.23 M/s | 152.18 Gbps | 18.5% | +| FrameRing batch publish | cross-thread 1514 bytes x batch=1 | 87.00 | 84.55 | 11.49 M/s | 139.22 Gbps | 3.3% | +| | cross-thread 1514 bytes x batch=4 | 74.34 | 71.26 | 13.45 M/s | 162.93 Gbps | 11.1% | +| | cross-thread 1514 bytes x batch=8 | 72.75 | 67.75 | 13.75 M/s | 166.48 Gbps | 16.1% | +| | cross-thread 1514 bytes x batch=32 | 66.64 | 62.61 | 15.01 M/s | 181.74 Gbps | 11.5% | +| | cross-thread 1514 bytes x batch=64 | 67.90 | 64.64 | 14.73 M/s | 178.38 Gbps | 9.3% | +| | cross-thread 64 bytes x batch=1 | 15.28 | 15.01 | 65.45 M/s | 33.51 Gbps | 5.3% | +| | cross-thread 64 bytes x batch=32 | 10.69 | 10.35 | 93.56 M/s | 47.90 Gbps | 5.8% | +| Statistics counters | relaxed load+store (this project) | 0.34 | 0.33 | 2.96 G/s | - | 13.2% | +| | fetch_add comparison | 2.04 | 1.99 | 490.26 M/s | - | 4.9% | + +> Spread = (worst round - best round) / median. Above 30% is flagged, indicating +> background interference or scheduling onto an efficiency core; treat absolute values as indicative only. + +## RNDIS encode/decode (tk_rndis) + +| Group | Benchmark | Median ns/op | Best ns/op | Rate | Throughput | Spread | |---|---|---:|---:|---:|---:|---:| -| RNDIS 编码 | 1514 字节 × 每传输 1 帧 | 35.07 | 34.86 | 28.51 M/s | 345.32 Gbps | 2.6% | -| | 1514 字节 × 每传输 4 帧 | 35.53 | 35.34 | 28.14 M/s | 340.87 Gbps | 1.6% | -| | 1514 字节 × 每传输 10 帧 | 36.57 | 36.46 | 27.35 M/s | 331.22 Gbps | 1.1% | -| | 64 字节 × 每传输 10 帧 | 4.77 | 4.65 | 209.79 M/s | 107.41 Gbps | 6.9% | -| | 1514 字节 × 每传输 10 帧 × 对齐 128 | 39.12 | 38.86 | 25.56 M/s | 309.59 Gbps | 1.4% | -| RNDIS 解码(零拷贝) | 1514 字节 × 每传输 1 帧 | 1.76 | 1.73 | 569.44 M/s | — | 9.2% | -| | 1514 字节 × 每传输 4 帧 | 1.97 | 1.94 | 508.01 M/s | — | 8.1% | -| | 1514 字节 × 每传输 10 帧 | 2.32 | 2.29 | 430.78 M/s | — | 5.5% | -| | 64 字节 × 每传输 10 帧 | 2.32 | 2.29 | 431.94 M/s | — | 5.3% | -| RNDIS 编解码往返 | 1514 字节 × 每传输 10 帧 | 37.47 | 37.29 | 26.69 M/s | 323.21 Gbps | 2.3% | -| | 64 字节 × 每传输 10 帧 | 7.17 | 7.07 | 139.52 M/s | 71.43 Gbps | 3.3% | - -> 离散度 =(最差轮 − 最好轮)/ 中位数。超过 30% 会标 ⚠,说明测量期间存在 -> 后台干扰或被调度到了效率核,此时的绝对数值仅供参考。 - -## feth / BPF 链路层(tk_net,需 root) - -| 分组 | 基准 | 中位数 ns/op | 最好 ns/op | 速率 | 吞吐 | 离散度 | +| RNDIS encode | 1514 bytes x 1 frames per transfer | 35.07 | 34.86 | 28.51 M/s | 345.32 Gbps | 2.6% | +| | 1514 bytes x 4 frames per transfer | 35.53 | 35.34 | 28.14 M/s | 340.87 Gbps | 1.6% | +| | 1514 bytes x 10 frames per transfer | 36.57 | 36.46 | 27.35 M/s | 331.22 Gbps | 1.1% | +| | 64 bytes x 10 frames per transfer | 4.77 | 4.65 | 209.79 M/s | 107.41 Gbps | 6.9% | +| | 1514 bytes x 10 frames per transfer x 128-byte alignment | 39.12 | 38.86 | 25.56 M/s | 309.59 Gbps | 1.4% | +| RNDIS decode (zero-copy) | 1514 bytes x 1 frames per transfer | 1.76 | 1.73 | 569.44 M/s | - | 9.2% | +| | 1514 bytes x 4 frames per transfer | 1.97 | 1.94 | 508.01 M/s | - | 8.1% | +| | 1514 bytes x 10 frames per transfer | 2.32 | 2.29 | 430.78 M/s | - | 5.5% | +| | 64 bytes x 10 frames per transfer | 2.32 | 2.29 | 431.94 M/s | - | 5.3% | +| RNDIS encode/decode round trip | 1514 bytes x 10 frames per transfer | 37.47 | 37.29 | 26.69 M/s | 323.21 Gbps | 2.3% | +| | 64 bytes x 10 frames per transfer | 7.17 | 7.07 | 139.52 M/s | 71.43 Gbps | 3.3% | + +> Spread = (worst round - best round) / median. Above 30% is flagged, indicating +> background interference or scheduling onto an efficiency core; treat absolute values as indicative only. + +## feth / BPF link layer (tk_net, requires root) + +| Group | Benchmark | Median ns/op | Best ns/op | Rate | Throughput | Spread | |---|---|---:|---:|---:|---:|---:| -| BPF 写入(逐帧 write) | 1514 字节 | 2180.74 | 2134.71 | 458.56 K/s | 5.55 Gbps | 7.7% | -| | 64 字节 | 1933.50 | 1915.85 | 517.20 K/s | 0.26 Gbps | 3.4% | -| BPF 写入(BIOCSBATCHWRITE) | 1514 字节 × 每次 8 帧 | 1037.77 | 1021.23 | 963.60 K/s | 11.67 Gbps | 8.7% | -| | 1514 字节 × 每次 32 帧 | 805.04 | 787.05 | 1.24 M/s | 15.05 Gbps | 4.8% | -| | 1514 字节 × 每次 64 帧 | 854.08 | 824.37 | 1.17 M/s | 14.18 Gbps | 6.4% | -| | 1514 字节 × 每次 128 帧 | 841.47 | 835.15 | 1.19 M/s | 14.39 Gbps | 2.3% | -| | 64 字节 × 每次 64 帧 | 728.20 | 701.79 | 1.37 M/s | 0.70 Gbps | 5.9% | -| feth 往返(写+读合计) | 1514 字节 × 每批 64 帧 | 1308.84 | 1147.53 | 764.04 K/s | 9.25 Gbps | 18.9% | -| feth 往返延迟 | ARP 请求 → 系统侧 IP 栈应答 | 24538.54 | 21956.04 | 40.75 K/s | — | 24.7% | +| BPF write (per-frame write) | 1514 bytes | 2180.74 | 2134.71 | 458.56 K/s | 5.55 Gbps | 7.7% | +| | 64 bytes | 1933.50 | 1915.85 | 517.20 K/s | 0.26 Gbps | 3.4% | +| BPF write (BIOCSBATCHWRITE) | 1514 bytes x 8 frames per write | 1037.77 | 1021.23 | 963.60 K/s | 11.67 Gbps | 8.7% | +| | 1514 bytes x 32 frames per write | 805.04 | 787.05 | 1.24 M/s | 15.05 Gbps | 4.8% | +| | 1514 bytes x 64 frames per write | 854.08 | 824.37 | 1.17 M/s | 14.18 Gbps | 6.4% | +| | 1514 bytes x 128 frames per write | 841.47 | 835.15 | 1.19 M/s | 14.39 Gbps | 2.3% | +| | 64 bytes x 64 frames per write | 728.20 | 701.79 | 1.37 M/s | 0.70 Gbps | 5.9% | +| feth round trip (write+read total) | 1514 bytes x 64 frames per batch | 1308.84 | 1147.53 | 764.04 K/s | 9.25 Gbps | 18.9% | +| feth round-trip latency | ARP request -> system-side IP stack reply | 24538.54 | 21956.04 | 40.75 K/s | - | 24.7% | -> 离散度 =(最差轮 − 最好轮)/ 中位数。超过 30% 会标 ⚠,说明测量期间存在 -> 后台干扰或被调度到了效率核,此时的绝对数值仅供参考。 +> Spread = (worst round - best round) / median. Above 30% is flagged, indicating +> background interference or scheduling onto an efficiency core; treat absolute values as indicative only. -## 结论与解读 +## Conclusions and analysis -### 1. 批量发布仍然有效,但收益比早期记录的小得多 +### 1. Batch publishing still helps, but the gain is much smaller than the earlier record -同一条队列、同样的搬运量,只改「每次 release store 发布多少帧」: +Same queue, same transfer volume, changing only "how many frames per release +store": -| 帧长 | batch=1 | batch=8 | batch=32 | 提速 | +| Frame size | batch=1 | batch=8 | batch=32 | Speedup | |---|---:|---:|---:|---:| -| 1514 字节 | ~87 ns/帧 | ~73 ns/帧 | ~67 ns/帧 | **约 1.3 倍** | -| 64 字节 | ~15 ns/帧 | — | ~11 ns/帧 | **约 1.4 倍** | +| 1514 bytes | ~87 ns/frame | ~73 ns/frame | ~67 ns/frame | **about 1.3x** | +| 64 bytes | ~15 ns/frame | - | ~11 ns/frame | **about 1.4x** | -原因不变:发布是一次 `release store`,会让写位置所在的缓存行跨核弹一次; -批量发布把这个固定成本摊薄成 `1/N`。 +The reason is unchanged: a publish is a `release store` that bounces the cache +line holding the write index across cores once; batch publishing amortizes that +fixed cost by `1/N`. -⚠️ **本节的倍数与本文档早期版本(2.4 倍 / 5.0 倍)对不上。** 差异来自机器换代 —— -早期数字测于另一台机器,本轮测于 Apple M5。M5 上跨核缓存行弹跳本身就便宜得多 -(`batch=1` 从 ~134 ns 降到 87 ns),于是可被摊薄的部分变小了。 -**结论方向没变(批量发布仍是净收益、仍应一律使用),但别再引用那两个倍数。** +Note: **the multipliers in this section do not match the early version of this +document (2.4x / 5.0x).** The difference comes from a machine change -- the +early numbers were measured on a different machine, this round on an Apple M5. +On M5 the cross-core cache-line bounce is much cheaper to begin with (`batch=1` +dropped from ~134 ns to 87 ns), so the part that can be amortized is smaller. +**The direction of the conclusion is unchanged (batch publishing is still a net +win and should always be used), but do not cite those two multipliers.** -> 实现见 `FrameRing::BatchWrite` / `BatchRead`。数据路径上**一律**用它们, -> 不要用逐帧的 `TryPush` / `BeginRead`。 +> Implementation: `FrameRing::BatchWrite` / `BatchRead`. The data path must +> **always** use them; do not use per-frame `TryPush` / `BeginRead`. -### 2. 瓶颈确实是系统调用 —— 而且比原先估的还贵 +### 2. The bottleneck really is system calls -- and more expensive than first estimated -本节此前用的 ~700 ns 来自调研的 `write(/dev/null, 1514)`,**不是**真实 BPF -描述符上的测量。现在有实测值了,而且**实测比估值贵约 3 倍**: +The ~700 ns used earlier in this section came from an investigation with +`write(/dev/null, 1514)`, **not** a measurement on a real BPF descriptor. We +now have measured values, and **the measurement is about 3x the estimate**: -| 每帧工作 | 成本 | +| Per-frame work | Cost | |---|---:| -| RNDIS 编码(含 memcpy) | ~35 ns | -| RNDIS 解码(零拷贝) | ~1.8 ns | -| FrameRing 跨线程搬运(batch=32) | ~67 ns | -| 统计计数器 | ~0.34 ns | -| **合计 CPU** | **约 105 ns** | -| 一次 BPF `write()`(逐帧,**实测**) | **约 2180 ns** | -| BPF 批量写摊到每帧(≥32 帧,**实测**) | **约 805 ns** | - -**逐帧系统调用是 CPU 成本的 20 倍以上**,批量写之后仍有 8 倍。这解释了架构里 -几乎所有关键决定: - -- RX 用 `BIOCSBATCHWRITE` 一次系统调用发整批; -- TX 用 RNDIS 多包聚合,让一次 bulk OUT 承载多帧; -- 在 `INITIALIZE_MSG` 里把 `MaxTransferSize` 报大(默认 16 KiB 而非 Linux 的 - 2048),这是让**设备**把多帧聚合进一次 bulk IN 的唯一手段; -- 绝不用 kqueue 做轮询。 - -### 3. BPF 写入的成本几乎与帧长无关 —— 所以批量写才这么关键 - -| 写法 | 1514 字节 | 64 字节 | +| RNDIS encode (including memcpy) | ~35 ns | +| RNDIS decode (zero-copy) | ~1.8 ns | +| FrameRing cross-thread transfer (batch=32) | ~67 ns | +| statistics counters | ~0.34 ns | +| **CPU total** | **about 105 ns** | +| one BPF `write()` (per frame, **measured**) | **about 2180 ns** | +| BPF batched write amortized per frame (>=32 frames, **measured**) | **about 805 ns** | + +**A per-frame system call is more than 20x the CPU cost**; after batching it is +still 8x. This explains almost every key decision in the architecture: + +- RX uses `BIOCSBATCHWRITE` to send a whole batch in one system call; +- TX uses RNDIS multi-packet aggregation so one bulk OUT carries multiple + frames; +- `MaxTransferSize` is reported large in `INITIALIZE_MSG` (16 KiB by default + instead of Linux's 2048), which is the only way to make the **device** + aggregate multiple frames into one bulk IN; +- kqueue is never used for polling. + +### 3. BPF write cost is almost independent of frame size -- which is why batching matters so much + +| Write method | 1514 bytes | 64 bytes | |---|---:|---:| -| 逐帧 `write()` | 2181 ns | 1934 ns | -| 批量 32 帧 | 805 ns/帧 | ~728 ns/帧(batch=64) | - -64 字节帧只比 1514 字节帧便宜 **13%**,而数据量差了 23 倍 —— 说明这笔开销 -**几乎全是每次调用的固定成本**(陷入内核、取 BPF 描述符锁、走 `bpf_movein` -与接口输出路径),与实际搬运的字节数基本无关。 - -批量写的收益在 **batch=32 就饱和**(32/64/128 帧分别是 805 / 854 / 841 ns/帧, -差异已落进噪声)。再往上加批量只是增加延迟,不再换来吞吐。 - -**这条同时给出了一个安心的结论**:批量写下 805 ns/帧 ≈ 124 万帧/秒, -1514 字节时约 15 Gbps —— 而 USB 2.0 高速满速也只需约 2.7 万帧/秒。 -**BPF 写入远不是本项目的瓶颈**,哪怕退回逐帧写(46 万帧/秒)也还有 17 倍余量。 -早先「BPF 写入决定 RX 方向理论上限」的担心可以放下了。 - -### 4. feth 数据路径的往返延迟约 24 µs - -从驱动侧 BPF 写一个 ARP 请求,到读回系统侧 IP 栈的 reply,中位数 **24.5 µs**。 -这里面包含两次 BPF 系统调用、feth 对的两次穿越、以及对侧 IP 栈处理 ARP 的时间。 -作为对比,真机上主机 ↔ 手机的 ICMP 往返是 **约 400~600 µs**,即 feth 这一段 -只占端到端延迟的 **约 5%** —— 延迟的大头在 USB 与设备侧,不在虚拟网卡。 - -### 5. 聚合度对编解码本身几乎没有影响 - -RNDIS 编码在「每传输 1 / 4 / 10 帧」下是 35.1 / 35.5 / 36.6 ns/帧, -解码是 1.8 / 2.0 / 2.3 ns/帧。也就是说**聚合的收益全部来自减少系统调用与 USB -往返,而不是省 CPU**。对齐要求(`PacketAlignmentFactor`)让编码从 36.6 涨到 -39.1 ns(多一次 memset 与 MessageLength 回填),约 7%,可以忽略。 - -### 6. 解码是零拷贝的,它的「吞吐」不可与编码比较 - -解码器只解析头部并返回指向传输缓冲内部的视图,**一个字节都不搬**,因此表格里 -它的吞吐列刻意留空(`—`)。如果硬给它算 Gbps 会得到上万这种荒谬数字。 -编码那边是真的在 memcpy 整帧,两者的 ns/op 可比,Gbps 不可比。 - -### 7. relaxed load+store 比 fetch_add 快 6 倍 - -统计计数器用 `relaxed load + store`(0.34 ns)而非 `fetch_add`(2.04 ns)。 -差距来自 arm64 的 LSE 原子指令 `ldadd` —— 它必须获得缓存行的独占所有权。 -因为每个计数器只有唯一的写线程,我们不需要读-改-写的原子性。 - -每帧省下的 1.7 ns 本身微不足道;真正的价值在于**避免两个方向的计数器争抢同一条 -缓存行** —— 一旦跨核争用,成本会从 2 ns 跳到 60 ns 以上。 - -### 8. 未对齐读取零代价 - -解析 44 字节 RNDIS 包头(11 次 `LoadLe32`,刻意放在奇数偏移上)约 1.03 ns, -即约 0.09 ns/字段。证实了 `byte_order.h` 里「`memcpy` + `byteswap` 会被 clang -优化成单条 `ldr`」的说法。 +| per-frame `write()` | 2181 ns | 1934 ns | +| batched 32 frames | 805 ns/frame | ~728 ns/frame (batch=64) | + +A 64-byte frame is only **13%** cheaper than a 1514-byte frame, while the data +volume differs by 23x -- meaning this cost is **almost entirely the fixed cost +of each call** (kernel entry, taking the BPF descriptor lock, going through +`bpf_movein` and the interface output path), largely independent of the number +of bytes actually moved. + +The gain from batching saturates at **batch=32** (32/64/128 frames are +805 / 854 / 841 ns/frame, differences within noise). Larger batches only add +latency without buying throughput. + +**This also gives a reassuring conclusion**: 805 ns/frame under batching is +about 1.24 million frames per second, about 15 Gbps at 1514 bytes -- while full +USB 2.0 High Speed needs only about 27,000 frames per second. **BPF writes are +far from this project's bottleneck**; even falling back to per-frame writes +(460,000 frames per second) leaves 17x headroom. The earlier worry that "BPF +writes determine the theoretical RX ceiling" can be put to rest. + +### 4. The feth data-path round-trip latency is about 24 us + +From writing an ARP request on the driver-side BPF to reading back the reply +from the system-side IP stack, the median is **24.5 us**. This includes two BPF +system calls, two crossings of the feth pair, and the peer IP stack's ARP +processing time. For comparison, host <-> phone ICMP round trips on the real +device are **about 400-600 us**, so this feth segment accounts for only +**about 5%** of end-to-end latency -- the bulk of the latency is in USB and on +the device side, not in the virtual interface. + +### 5. Aggregation degree has almost no effect on encode/decode itself + +RNDIS encode is 35.1 / 35.5 / 36.6 ns/frame at "1 / 4 / 10 frames per +transfer", and decode is 1.8 / 2.0 / 2.3 ns/frame. In other words, **the gain +from aggregation comes entirely from reducing system calls and USB round trips, +not from saving CPU**. The alignment requirement (`PacketAlignmentFactor`) +raises encode from 36.6 to 39.1 ns (one extra memset and MessageLength fill), +about 7%, which is negligible. + +### 6. Decode is zero-copy; its "throughput" is not comparable to encode + +The decoder only parses headers and returns views into the transfer buffer; it +moves **zero bytes**, so its throughput column is intentionally left empty +(`-`) in the table. Computing Gbps for it would produce absurd numbers in the +tens of thousands. Encode really does memcpy the whole frame; their ns/op values +are comparable, their Gbps are not. + +### 7. relaxed load+store is 6x faster than fetch_add + +Statistics counters use `relaxed load + store` (0.34 ns) rather than +`fetch_add` (2.04 ns). The difference comes from arm64's LSE atomic instruction +`ldadd`, which must obtain exclusive ownership of the cache line. Because each +counter has only one writer thread, we do not need read-modify-write atomicity. + +The 1.7 ns saved per frame is negligible on its own; the real value is +**avoiding contention on the same cache line between the counters of the two +directions** -- once they contend across cores, the cost jumps from 2 ns to more +than 60 ns. + +### 8. Unaligned reads are free + +Parsing a 44-byte RNDIS header (11 `LoadLe32` calls, deliberately placed at an +odd offset) takes about 1.03 ns, about 0.09 ns/field. This confirms the claim +in `byte_order.h` that "`memcpy` + `byteswap` is optimized by clang into a +single `ldr`". --- -## 真机端到端实测 +## Real-device end-to-end measurements -**被测设备**:vivo iQOO Z10x(型号 V2445A / PD2445M),Android 15(SDK 35), -USB `2d95:600b`,RNDIS 接口签名 `e0/01/03`(无线类变体),协商为 **USB 2.0 高速**。 -手机侧 iperf3 3.21 为用 NDK r28 交叉编译的静态 arm64 二进制;主机侧 iperf3 3.21。 -MTU 1500,测试期间手机作为网关(`rndis0` = 192.168.35.7/24)。 +**Device under test**: vivo iQOO Z10x (model V2445A / PD2445M), Android 15 +(SDK 35), USB `2d95:600b`, RNDIS interface signature `e0/01/03` (wireless +class variant), negotiated to **USB 2.0 High Speed**. iperf3 3.21 on the phone +was cross-compiled as a static arm64 binary with NDK r28; iperf3 3.21 on the +host. MTU 1500, the phone acted as gateway during testing +(`rndis0` = 192.168.35.7/24). -| 方向 | TCP | UDP(灌入 250 Mbit) | +| Direction | TCP | UDP (flood 250 Mbit) | |---|---:|---:| -| **RX**(设备 → 主机) | **324~327 Mbps**,**0 重传** | — | -| **TX**(主机 → 设备) | 234~241 Mbps,数千次重传 | 交付 142~161 Mbps,**丢 35~43%** | +| **RX** (device -> host) | **324~327 Mbps**, **0 retransmits** | - | +| **TX** (host -> device) | 234~241 Mbps, thousands of retransmits | delivered 142~161 Mbps, **35~43% drop** | -USB 2.0 高速下 RNDIS 的有效载荷理论上限约 **426 Mbps**,RX 方向达到了它的 **77%**。 +The theoretical effective payload ceiling for RNDIS over USB 2.0 High Speed is +about **426 Mbps**; RX reached **77%** of it. -### 三条实测结论 +### Three measured conclusions -**① RX 干净,TX 是弱侧。** RX 在所有配置下都稳定在 ~325 Mbps 且零重传、零丢帧; -TX 在同样的链路上只能到 ~235 Mbps,且伴随数千次 TCP 重传。 +**1) RX is clean; TX is the weak side.** RX stayed stable at ~325 Mbps in all +configurations with zero retransmits and zero frame drops; TX reached only +~235 Mbps on the same link with thousands of TCP retransmits. -**② TX 的丢帧发生在 TetherKit 自己的 TX 路径上,不是设备拒收。** -运行时统计直接给出证据(`--tx-transfers 4`,默认值): +**2) TX frame drops happen on TetherKit's own TX path, not due to device +rejection.** Runtime statistics provide direct evidence (with `--tx-transfers 4`, +the default): ``` -TX 20213 pps / 244.82 Mbps(丢 1889) | 队列深度 0 内核丢包 0 背压 1155 +TX 20213 pps / 244.82 Mbps (dropped 1889) | queue depth 0 kernel drops 0 backpressure 1155 ``` -`丢` 与 `背压` 同步增长,说明 BPF 读上来的帧因为拿不到空闲的 bulk OUT 传输而被丢弃。 -**根因与修复见下一节** —— 简言之:桥接层在传输池占满时立即丢弃批次剩余帧, -而它本该等几百微秒。 +`dropped` and `backpressure` increase in lockstep, showing that frames read +from BPF are being dropped because no free bulk OUT transfer is available. +**The root cause and fix are described in the next section** -- in brief, the +bridge discarded the rest of a batch as soon as the transfer pool was full +instead of waiting a few hundred microseconds. -**③ TX 的问题不是 RNDIS 多包聚合。** 本机 Android 汇报 -`MaxPacketsPerMessage=10`,一度怀疑设备其实只拆第一个包(主线 Linux gadget 的 -`rndis_rm_hdr()` 正是每次只拆一个)。用新增的 `--max-tx-packets` 做 A/B 后 -**该假设被证伪**: +**3) The TX problem is not RNDIS multi-packet aggregation.** The local Android +reported `MaxPacketsPerMessage=10`; there was a hypothesis that the device only +split the first packet (as mainline Linux gadget's `rndis_rm_hdr()` does). +Testing with the newly added `--max-tx-packets` A/B proved **that hypothesis +false**: -| 每传输聚合包数 | UDP 丢包率 | TCP TX | +| packets aggregated per transfer | UDP drop rate | TCP TX | |---|---:|---:| -| 1(钳死) | 42% | 125 Mbps | +| 1 (capped) | 42% | 125 Mbps | | 3 | 43% | 237 Mbps | -| 10(设备汇报值,默认) | 35~58% | 234~241 Mbps | +| 10 (device-reported, default) | 35~58% | 234~241 Mbps | -钳到 1 反而**更差**。聚合是有效的,问题在别处(见 ②)。 +Capping to 1 was **worse**. Aggregation is effective; the problem lies +elsewhere (see 2). -### 双向并发时 TX 曾塌陷到 4.8 Mbps +### Bidirectional concurrency collapsed TX to 4.8 Mbps before the fix -修复前,单向各自正常,但同时跑双向 TCP 时: +Before the fix, each direction was fine alone, but running bidirectional TCP +together produced: ``` [TX-C] 4.82 Mbits/sec 471 retr cwnd 11.3 KBytes [RX-C] 320 Mbits/sec 0 retr ``` -TX 塌到 **4.8 Mbps**,约为单向时的 1/50。当时的机理分析是**缓冲膨胀**: +TX collapsed to **4.8 Mbps**, about 1/50 of the unidirectional rate. The +mechanistic analysis at the time pointed to **bufferbloat**: -- 用 UDP 复测排除了 USB 带宽争抢 —— RX 满载时并发灌 50 Mbit UDP,TX 丢包只有 - **0.32%**,两个方向合计 348 Mbps 都正常; -- 但 RX 满载会把往返时延从空闲时的 **0.4 ms 抬到 16.6 ms**(40 倍); -- TX 流的 ACK 要走这条被塞满的 RX 路径回来,于是 cwnd 卡在 11 KB, - `11 KB × 8 / 16.6 ms ≈ 5.3 Mbps` —— 与实测的 4.8 Mbps 吻合。 +- UDP retests ruled out USB bandwidth contention -- with RX at full load and + 50 Mbit UDP injected concurrently, TX drops were only **0.32%** and the two + directions together reached 348 Mbps; +- but full RX load pushed round-trip latency from **0.4 ms** idle to + **16.6 ms** (40x); +- ACKs for the TX flow had to travel back over that saturated RX path, so the + congestion window got stuck at 11 KB and `11 KB x 8 / 16.6 ms ~= 5.3 Mbps` -- + matching the measured 4.8 Mbps. -这个算式是对的,但它只解释了**后果**。真正的第一因是 cwnd 为什么会卡在 11 KB —— -是 TX 侧在丢帧。把丢帧修掉之后,双向 TX 从 4.8 直接回到 **87 Mbps**, -而 RTT 膨胀本身并没有被处理。**所以 bufferbloat 是次要因素,不是根因。** +The arithmetic was correct, but it only explained the **consequence**. The +true first cause was why the cwnd got stuck at 11 KB -- it was because TX was +dropping frames. After fixing frame drops, bidirectional TX went straight back +to **87 Mbps** even though RTT inflation itself was not addressed. **So +bufferbloat was a secondary factor, not the root cause.** --- -## TX 丢帧的根因与修复 +## Root cause and fix for TX frame drops -**根因**(一行):`Bridge::RunTransmitExtractor` 在 USB 传输池占满时**立即丢弃** -本批剩余帧,而它本该等几百微秒。 +**Root cause** (in one line): `Bridge::RunTransmitExtractor` **immediately +dropped** the rest of a batch when the USB transfer pool was full, instead of +waiting a few hundred microseconds. -旧代码在 `SendFrames` 返回 0 时执行 `AddDroppedFull(剩余) + break`,附了一条 -理由充分得很有说服力、但事实错误的注释: +The old code did `AddDroppedFull(remaining) + break` when `SendFrames` +returned 0 and carried a persuasive but factually wrong comment: -> 这里刻意**丢弃**而不是等待:等待会让 BPF 的内核缓冲堆积,最终由内核丢包 -> (而且是我们看不见的丢包)。在用户态丢并计数,运维才知道发生了什么。 +> Here we deliberately **drop** rather than wait: waiting would let the BPF +> kernel buffer accumulate and eventually be dropped by the kernel (and those +> drops would be invisible to us). Dropping in userspace and counting lets +> operators know what happened. -两处都不成立: +Neither premise holds: -1. **内核丢包并非「看不见」。** `BpfLink::ReadFrames` 每一批都读 `BIOCGSTATS` - 把 `bs_drop` 带回来,它就显示在统计行的「内核丢包」列里。 -2. **内核缓冲正是该吸收突发的那一级。** 默认 4 MiB,在 250 Mbps 下约等于 - 130 ms 的弹性;而传输槽位的实际释放周期只有几百微秒(一次 bulk OUT 的完成 - 时间)。旧代码一微秒都不肯等,把本该由 4 MiB 缓冲吸收的突发直接丢在了 - 用户态。 +1. **Kernel drops are not invisible.** `BpfLink::ReadFrames` reads + `BIOCGSTATS` every batch and brings `bs_drop` back; it shows up in the + statistics line under "kernel drops". +2. **The kernel buffer is exactly there to absorb bursts.** The default 4 MiB + is about 130 ms of headroom at 250 Mbps; the transfer slots are freed every + few hundred microseconds (once a bulk OUT completes). The old code refused + to wait even a microsecond and dropped bursts that the 4 MiB buffer was meant + to absorb. -量级对不上得很离谱:设备汇报 15800 字节 / 10 包,一个 256 帧的提交分片需要约 -**26 个传输槽**,而池里只有 **4 个**。于是每个分片必然在第 4 个槽之后撞上背压, -剩下 200 多帧当场丢弃 —— 这就是那 30%+ 的 TX 丢包率。 +The magnitude mismatch was extreme: the device reported 15800 bytes / 10 +packets, so a 256-frame submission slice needs about **26 transfer slots** while +the pool has only **4**. Every slice was guaranteed to hit backpressure after +the 4th slot and drop the remaining ~200 frames on the spot -- that is what +caused the ~30%+ TX drop rate. -**修复**:`SendFrames` 改为返回 `SendOutcome{consumed, sent_frames, sent_bytes, -skipped}`;桥接层在 `consumed == 0` 时调 `WaitForSendCapacity(50ms)` 等槽位 -(由完成回调用条件变量唤醒),醒来后回到循环顶部重查停机/暂停标志再重试, -**一帧都不丢**。溢出兜底交还给内核 BPF 缓冲,它满了才丢、且丢在统计里可见。 +**Fix:** `SendFrames` now returns +`SendOutcome{consumed, sent_frames, sent_bytes, skipped}`; when +`consumed == 0` the bridge calls `WaitForSendCapacity(50ms)` to wait for a +slot (woken by the completion callback's condition variable), returns to the +top of the loop to recheck stop/pause flags, and retries -- **dropping zero +frames**. Any overflow falls back to the kernel BPF buffer, where it is dropped +only when full and remains visible in statistics. -### 修复效果(同一台设备,默认参数) +### Fix effect (same device, default parameters) -| 指标 | 修复前 | 修复后 | +| Metric | Before | After | |---|---:|---:| -| TetherKit 自己的 TX 丢帧(6~8 秒窗口) | 1889 | **0** | -| TCP TX 重传 | 3829 | **0** | -| TCP TX 吞吐 | 234~241 Mbps | 239~302 Mbps | -| **双向并发 TX** | **4.8 Mbps** | **87 Mbps** | -| 双向并发 RX | 320 Mbps | 240 Mbps | -| 单向 TCP RX(回归检查) | 324~327 Mbps | 326~327 Mbps | - -两点必须诚实说明: - -- **双向 RX 从 320 降到 240。** 这不是退步而是公平性恢复:修复前 TX 被饿死到 - 4.8 Mbps,RX 独占了链路;现在两个方向各得其所,合计从 325 Mbps 变成 - 约 327 Mbps —— 总量没变,分配变了。 -- **TCP TX 是双峰的**(约 240 或约 300 Mbps,两个稳定态),三次重复里都能看到 - 两种值。原因未查明,但两种状态都是零重传,不是丢包造成的。 - -### 附带结论:`--tx-transfers` 不再需要调 - -修复前把在飞传输数从 4 提到 16 能把丢帧降到约 1/4,那是在拿池深度补偿 -「不肯等待」这个缺陷。修复后重复三次实测: - -| `--tx-transfers` | TCP TX(3 次) | 双向 TX | TCP RX | +| TetherKit TX frame drops (6-8 s window) | 1889 | **0** | +| TCP TX retransmits | 3829 | **0** | +| TCP TX throughput | 234~241 Mbps | 239~302 Mbps | +| **Bidirectional TX** | **4.8 Mbps** | **87 Mbps** | +| Bidirectional RX | 320 Mbps | 240 Mbps | +| Unidirectional TCP RX (regression check) | 324~327 Mbps | 326~327 Mbps | + +Two honest notes: + +- **Bidirectional RX fell from 320 to 240.** This is not a regression but a + restoration of fairness: before the fix TX was starved at 4.8 Mbps and RX + monopolized the link; now both directions get their share and the aggregate + went from 325 Mbps to about 327 Mbps -- total unchanged, allocation changed. +- **TCP TX is bimodal** (about 240 or about 300 Mbps, two stable states); + both states were seen across three repeats and both have zero retransmits, so + packet loss is not the cause. The reason remains unknown. + +### Side conclusion: `--tx-transfers` no longer needs tuning + +Before the fix, raising in-flight transfers from 4 to 16 cut drops to about 1/4 +by compensating for the "refuse to wait" bug with pool depth. After the fix, +three repeats showed no distinguishable difference: + +| `--tx-transfers` | TCP TX (3 runs) | Bidirectional TX | TCP RX | |---|---|---:|---:| -| 4(默认) | 239 / 297 / 241,重传 0 | 87.8 / 86.6 | 327 | -| 8 | 298 / 238 / 297,重传 0 | 86.9 / 85.7 | 326 | -| 16 | 302 / 302 / 217,重传 1 | 86.2 / 89.6 | 326 | +| 4 (default) | 239 / 297 / 241, 0 retransmits | 87.8 / 86.6 | 327 | +| 8 | 298 / 238 / 297, 0 retransmits | 86.9 / 85.7 | 326 | +| 16 | 302 / 302 / 217, 1 retransmit | 86.2 / 89.6 | 326 | -**三档没有可分辨的差异。** 默认值因此保持 4 —— 池深度本来就不该是吞吐的杠杆, -它只需要够让「组装下一批」与「上一批在飞」重叠起来。 +**No discernible difference across the three settings.** The default stays at 4 +-- pool depth should not be a throughput lever; it only needs to be enough to +overlap assembling the next batch with the previous batch in flight. -### 一个必须记住的排查陷阱:Android 的 `rx_errs` 计数器不可信 +### A pitfall to remember: Android's `rx_errs` counter is not trustworthy -手机侧 `/proc/net/dev` 的 `rndis0` 会在 `rx_errs` / `rx_frame` 上疯狂计数 -(实测一度占到收包数的 30~50%),很容易被当成「主机发的帧是坏的」。**它不是。** -用低速率对照实验可以立刻证伪: +On the phone side, `/proc/net/dev` for `rndis0` counts wildly in +`rx_errs` / `rx_frame` (up to ~30-50% of packets received in measurements), +which easily looks like "the host is sending bad frames". **It is not.** A +low-rate counterexample proves it immediately: ``` -50 个 ping → 50 个回应,0% 丢包;但 rx_pkts +51 的同时 rx_errs 也 +51 +50 pings -> 50 replies, 0% drop; but rx_pkts +51 and rx_errs +51 at the same time ``` -每一帧都同时被计入「收到」和「错误」,而应答完全正常。 -**排查 TX 问题时请以 iperf3 的实际丢包率与 TetherKit 自己的统计为准, -不要用手机侧的 `rx_errs`。** +Every frame is counted as both "received" and "error", yet replies are +completely normal. **When debugging TX issues, rely on iperf3's actual drop +rate and TetherKit's own statistics, not the phone-side `rx_errs`.** --- -## 测量方法与已知局限 - -- **微基准的数字都是 L1 常驻的上界。** 真实 RX 路径的源是 USB DMA 刚写入的缓冲, - 对 CPU 缓存是冷的;调研给的流式 memcpy 实测是 67~71 ns/1514 字节, - 而本文表格里的热缓存 memcpy 是 ~33 ns。请把表里的 CPU 侧数值当作**下界**。 - 链路层那一组(BPF / feth)不受此限 —— 它测的是真实系统调用。 -- **「RNDIS 编码比裸 memcpy 还快」这个历史疑点已经消失。** 本文早期版本记录过 - 一个无法解释的偏差:编码 1514 字节测出 ~14 ns/帧,比 `memcpy` 1514 字节的 - ~25 ns 还快。本轮在 Apple M5 上复测,编码 35.1 ns / memcpy 32.9 ns —— - 编码**略慢于** memcpy,符合直觉(它就是 memcpy 加一个 44 字节头)。 - 该偏差应归因于早期那台机器的访存行为,现已不复现,不必再追。 -- **离散度偏高的项**(`SPSC 跨线程搬运`、`FrameRing 单线程往返(64 字节)`、 - `feth 往返延迟`)应看**最好轮**。原因是自旋线程可能被调度到性能核或效率核的 - 不同组合上(本机 4 P + 6 E),而 macOS 不提供绑核接口 - (`thread_affinity_policy` 在 Apple Silicon 上基本无效)。 -- **链路层基准需要 root**,非 root 运行时该组不会静默消失,而是在报告里留下 - 「本次未测量」的说明。重新测量:`sudo ./build/bin/tetherkit_bench > docs/BENCHMARKS.md`。 - 注意夹具必须在 `main` 返回前显式拆除(`ShutdownNetBenchmarks`)—— 留给静态析构 - 会因日志互斥量已被销毁而抛异常,表现为「基准全跑完了但退出码是 134」。 -- 曾经踩过的一个测量错误:统计计数器最初测出 **0.00 ns/op**,因为 relaxed 原子的 - 连续读-改-写**允许**被编译器合并(N 次 +1 折叠成一次 +N)。加 `ClobberMemory` - 编译屏障后才得到真实的 0.34 ns。所以本项目的每个基准都必须自问一句 - 「被测的工作真的发生了吗」。 +## Measurement method and known limitations + +- **Microbenchmark numbers are upper bounds for L1-resident data.** The real + RX path's source is a USB DMA buffer just written by the device, which is + cold in the CPU cache; the streaming memcpy measured in investigation is + 67-71 ns/1514 bytes, while the hot-cache memcpy in this document's tables is + ~33 ns. Treat the CPU-side values in the tables as **lower bounds**. The + link-layer group (BPF / feth) is not subject to this -- it measures real + system calls. +- **The historical puzzle "RNDIS encode is faster than plain memcpy" has + disappeared.** An earlier version of this document recorded an unexplained + anomaly: encoding 1514 bytes measured ~14 ns/frame, faster than the + ~25 ns for `memcpy` of 1514 bytes. Remeasured on Apple M5 in this round, + encode is 35.1 ns / memcpy 32.9 ns -- encode is **slightly slower** than + memcpy, as intuition would suggest (it is memcpy plus a 44-byte header). That + anomaly should be attributed to the memory behavior of the earlier machine; + it no longer reproduces and does not need further investigation. +- **High-spread items** (`SPSC cross-thread transfer`, + `FrameRing single-thread round trip (64 bytes)`, `feth round-trip latency`) + should be read as the **best round**. The reason is that spinning threads may + be scheduled onto different mixes of performance and efficiency cores (this + machine has 4 P + 6 E), and macOS provides no CPU affinity interface + (`thread_affinity_policy` is effectively useless on Apple Silicon). +- **The link-layer benchmarks require root.** When run without root, the group + does not disappear silently; the report records "Not measured in this run". + To measure: `sudo ./build/bin/tetherkit_bench > docs/BENCHMARKS.md`. Note + that the fixture must be torn down explicitly before `main` returns + (`ShutdownNetBenchmarks`) -- leaving it to static destructors throws because + the logging mutex has already been destroyed, which shows up as "all + benchmarks finished but exit code 134". +- A measurement mistake was caught earlier: the statistics counter initially + measured **0.00 ns/op** because the compiler is **allowed** to coalesce + consecutive relaxed read-modify-writes (fold N times +1 into a single +N). + Adding a `ClobberMemory` compiler barrier produced the true 0.34 ns. Every + benchmark in this project must therefore ask itself: "did the measured work + actually happen?" --- -## 待补充的基准 - -原先挂在这里的四项**已经全部补齐**,见上文: - -- [x] BPF 写入成本(需 root)—— 逐帧 2181 ns / 批量 805 ns,见「结论」第 2、3 条; -- [x] BPF 批量读取的每帧成本 —— 以「feth 往返(写+读合计)」给出,1309 ns/帧; - 注意这一项是**写+读的合计**,harness 只能给整个闭包计时, - 要拿纯读取成本请减去同批大小下的写入值; -- [x] feth 数据路径的往返延迟 —— 24.5 µs,见「结论」第 4 条; -- [x] 真机端到端吞吐 —— RX 324~327 Mbps / TX 234~241 Mbps,见「真机端到端实测」。 - -- [x] TX 丢帧的**根因** —— 已查明并修复:桥接层在传输池占满时立即丢弃而非等待, - 见「TX 丢帧的根因与修复」。TetherKit 自己的 TX 丢帧从 1889 降到 0。 - -- [x] 修复后的优雅停机 —— 已在真终端确认:`sudo build/bin/tetherkit-cli` 再 Ctrl-C - 打出「已停机」,`ifconfig` 无 feth 残留。新增的背压等待(`WaitForSendCapacity`) - 没有卡住停机路径。⚠️ 这一条**只能在真终端里验证** —— 提权脚本会吞掉信号, - 见下文与 AGENTS.md 第 7 节第 15 条。 - -仍然空着的: - -- [ ] TCP TX 的**双峰行为**(约 240 vs 约 300 Mbps 两个稳定态,都是零重传)。 - 既然零重传就不是丢包,怀疑与 USB 微帧调度或设备侧的聚合时机有关,未验证。 -- [ ] 双向并发时 RTT 仍会从 0.4 ms 膨胀到 16.6 ms。修复 TX 丢帧后它已不再造成 - 吞吐塌陷,但延迟敏感的场景仍会受影响;缩小 RX 排队深度能改善多少未测。 -- [ ] 除本机外的 Android 版本 / 厂商内核的 RNDIS 参数分布 —— - 已知本机报 `10 包 / 15800 字节`,主线 Linux gadget 报 `1 包 / 1580 字节`, - 样本只有这两个。 +## Benchmarks still to add + +The four items previously listed here are **all complete**; see above: + +- [x] BPF write cost (requires root) -- 2181 ns per frame / 805 ns batched, see + "Conclusions" sections 2 and 3; +- [x] BPF batched-read per-frame cost -- given as "feth round trip (write+read + total)", 1309 ns/frame; note this is **write+read combined** because the + harness can only time the whole closure -- subtract the write value at the + same batch size to get the read-only cost; +- [x] feth data-path round-trip latency -- 24.5 us, see "Conclusions" section 4; +- [x] real-device end-to-end throughput -- RX 324~327 Mbps / TX 234~241 Mbps, + see "Real-device end-to-end measurements". + +- [x] **Root cause** of TX frame drops -- found and fixed: the bridge dropped + immediately when the transfer pool was full instead of waiting, see + "Root cause and fix for TX frame drops". TetherKit's own TX drops fell + from 1889 to 0. + +- [x] Graceful shutdown after the fix -- confirmed in a real terminal: + `sudo build/bin/tetherkit-cli` then Ctrl-C prints "stopped" with no feth + residue in `ifconfig`. The added backpressure wait + (`WaitForSendCapacity`) does not block the shutdown path. Note: this can + **only be verified in a real terminal** -- the privilege-escalation + wrapper swallows signals; see below and AGENTS.md section 7 item 15. + +Still open: + +- [ ] TCP TX **bimodal behavior** (about 240 vs about 300 Mbps, two stable + states, both with zero retransmits). Since there are zero retransmits this + is not packet loss; USB microframe scheduling or device-side aggregation + timing is suspected but unverified. +- [ ] Under bidirectional concurrency, RTT still inflates from 0.4 ms to + 16.6 ms. After fixing TX drops it no longer causes throughput collapse, + but latency-sensitive workloads are still affected; how much reducing RX + queue depth would help is unmeasured. +- [ ] Distribution of RNDIS parameters across Android versions / vendor kernels + beyond this device -- known samples are only this device + (`10 packets / 15800 bytes`) and mainline Linux gadget + (`1 packet / 1580 bytes`). --- -## ⚠️ 压测环境的一个陷阱:提权链会吞掉 SIGTERM +## A pitfall in the stress-test environment: the privilege-escalation chain swallows SIGTERM -`osascript ... with administrator privileges` 这条提权链**会吞掉进程间的 SIGTERM -投递**:脚本里 `kill -TERM $pid` 返回 0、目标进程的 `blocked` / `pending` 掩码都是 0、 -`sigaction` 装的 handler 也确实在位(`sample` 能看到 handler 地址不是 `SIG_IGN`), -但信号永远不会到达,进程只能 `kill -9`。 +The `osascript ... with administrator privileges` escalation chain **swallows +inter-process SIGTERM delivery**: `kill -TERM $pid` in the script returns 0, +the target process shows `blocked` / `pending` masks of 0, and the +`sigaction`-installed handler is indeed in place (`sample` shows the handler +address is not `SIG_IGN`), yet the signal never arrives and the process can +only be killed with `kill -9`. -用一个 15 行的 C 程序做过对照,与 TetherKit 无关: +A 15-line C program confirmed this independently of TetherKit: -| 执行环境 | 收到 SIGTERM? | +| Execution environment | Receives SIGTERM? | |---|---| -| 普通 shell | ✅ | -| 普通 `osascript do shell script` | ✅ | -| `osascript ... with administrator privileges` | ❌ | - -**后果:别用这条链验证优雅停机**,它会造出一个「停机卡死」的假故障,而且跟被测 -代码毫无关系 —— 修复前后的运行日志里都是清一色的 `Killed: 9`。要验证端到端停机, -在真正的终端里 `sudo build/bin/tetherkit-cli` 再 Ctrl-C(已如此验证,见上)。 +| plain shell | yes | +| plain `osascript do shell script` | yes | +| `osascript ... with administrator privileges` | no | + +**Consequence: do not use this chain to verify graceful shutdown.** It produces +a "shutdown hangs" false failure that has nothing to do with the code under +test -- logs before and after the fix were all `Killed: 9`. To verify +end-to-end shutdown, run `sudo build/bin/tetherkit-cli` in a real terminal and +press Ctrl-C (verified this way, as noted above). diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 35cdce5..292a7b8 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,17 +1,19 @@ -# TetherKit 设计文档 +# TetherKit Design Document -本文说明**为什么这样设计**。具体的字段偏移与常量见 -[RNDIS-PROTOCOL.md](RNDIS-PROTOCOL.md);实测数字见 [BENCHMARKS.md](BENCHMARKS.md); -已验证的环境事实与踩过的坑见 [../AGENTS.md](../AGENTS.md)。 +This document explains **why it is designed this way**. For field offsets and +constants, see [RNDIS-PROTOCOL.md](RNDIS-PROTOCOL.md); for measured numbers, +see [BENCHMARKS.md](BENCHMARKS.md); for verified environment facts and pitfalls, +see [../AGENTS.md](../AGENTS.md). --- -## 1. 问题与方案选择 +## 1. Problem and approach -macOS 内核**没有** RNDIS 驱动。这不是推测 —— 逐个检查了 -`/System/Library/Extensions` 下所有 CDC 家族 kext 的 `IOKitPersonalities`: +The macOS kernel **has no** RNDIS driver. This is not speculation -- every +`IOKitPersonalities` entry of every CDC-family kext under +`/System/Library/Extensions` was inspected: -| kext personality | 匹配条件 | +| kext personality | match condition | |---|---| | `AppleUSBACMControl0` | class 2, subclass 2, protocol **0** | | `AppleUSBACMControl1` | class 2, subclass 2, protocol **1** | @@ -19,346 +21,404 @@ macOS 内核**没有** RNDIS 驱动。这不是推测 —— 逐个检查了 | `AppleUSBNCMControl` | class 2, subclass **13** | | `AppleUSBWCMControl` | class 2, subclass **8** | -RNDIS 的通信类接口是 `{0x02, 0x02, 0xFF}` —— protocol 既不是 0 也不是 1, -**没有任何 personality 匹配**;Android 常用的 `{0xE0, 0x01, 0x03}` 变体连 -class `0xE0` 的 personality 都不存在。 +RNDIS communication-class interfaces are `{0x02, 0x02, 0xFF}` -- the protocol +is neither 0 nor 1, so **no personality matches**; the `{0xE0, 0x01, 0x03}` +variant commonly used by Android does not even have a personality for class +`0xE0`. -于是要在 macOS 上用起 RNDIS 设备,只有四条路: +That leaves four ways to make an RNDIS device work on macOS: -| 方案 | 能否纯用户态 | 门槛 | 语义层级 | 结论 | +| Approach | Pure userspace? | Requirement | Semantic level | Verdict | |---|---|---|---|---| -| kext / NKE | ❌ | Apple 授予的 kext 签名 entitlement 或关 SIP | L2 | Apple Silicon 上实质不可行 | -| `NEPacketTunnelProvider` | ✔ | 付费开发者账号 + entitlement + App bundle | **L3** | 要自己实现 ARP/ND/DHCP | -| `utun` | ✔ | root | **L3** | 同上,且要自己拆装以太头 | -| **`feth` + BPF** | ✔ | root | **L2** | ✅ 本项目选择 | - -选 `feth` + BPF 的决定性理由是**语义层级匹配**:RNDIS 天然是 L2(承载完整以太帧), -而 `feth` + BPF 是唯一能在纯用户态双向收发**原始以太帧**的路径。这样手机侧的 -DHCP 服务器与 ARP 广播直接对主机 IP 栈生效,我们完全不需要自己实现 ARP 代答、 -邻居发现或 DHCP 客户端 —— 那三样才是 L3 方案真正的复杂度所在。 - -代价:需要 root(`feth` 创建与 `/dev/bpf*` 都要),每方向多一次 mbuf 拷贝, -TX 有「整帧 ≤ MTU + 18」的长度限制。 +| kext / NKE | no | kext signing entitlement granted by Apple, or SIP disabled | L2 | Effectively infeasible on Apple Silicon | +| `NEPacketTunnelProvider` | yes | paid developer account + entitlement + app bundle | **L3** | Must implement ARP/ND/DHCP ourselves | +| `utun` | yes | root | **L3** | Same, and we must strip and add Ethernet headers ourselves | +| **`feth` + BPF** | yes | root | **L2** | chosen by this project | + +The decisive reason for `feth` + BPF is **semantic-level match**: RNDIS is +natively L2 (it carries full Ethernet frames), and `feth` + BPF is the only +path that can send and receive **raw Ethernet frames** in both directions from +pure userspace. This lets the phone-side DHCP server and ARP broadcasts reach +the host IP stack directly, so we do not need to implement ARP proxying, +neighbor discovery, or a DHCP client -- those three are the real complexity of +an L3 approach. + +Costs: root is required (for `feth` creation and `/dev/bpf*`), one extra mbuf +copy per direction, and TX has a "whole frame <= MTU + 18" length limit. --- -## 2. 总体架构 +## 2. Overall architecture ``` - ┌──────────────────────── macOS 内核 ────────────────────────┐ - │ IP 栈 / 路由 / DHCP 客户端 │ - │ │ │ - │ ▼ │ - │ ┌─────────┐ if_fake peer 对 ┌─────────┐ │ - │ │ feth0 │ ◄─────────────────► │ feth1 │ │ - │ │(系统侧) │ │(驱动侧) │ │ - │ └─────────┘ └────┬────┘ │ - │ 配 IP/路由 │ BPF(单个描述符 │ - │ MAC = 设备汇报的地址 │ 同时收发) │ - └───────────────────────────────────────┼────────────────────┘ - │ - ┌───────────────────────────────────────┼────────────────────┐ - │ TetherKit(用户态) ▼ │ - │ ┌────────────── core::Bridge(3 个数据路径线程)────────┐ │ - │ │ RX: bulk IN 回调 → FrameRing → BPF 批量 write │ │ - │ │ TX: BPF 批量 read → RNDIS 多包聚合 → bulk OUT │ │ - │ └──────────────────────┬───────────────────────────────┘ │ - │ ┌───────── rndis::StateMachine(控制线程)─────────┐ │ - │ │ INITIALIZE / QUERY / SET / KEEPALIVE / RESET / │ │ - │ │ HALT / INDICATE_STATUS │ │ - │ └──────────────────────┬──────────────────────────┘ │ - │ ┌───────── usb::Device / DataChannel ─────────────┐ │ - │ │ 控制通道(EP0 类请求)+ 中断 IN 通知 │ │ - │ │ 异步 bulk IN/OUT 传输池 │ │ - │ └──────────────────────┬──────────────────────────┘ │ - └─────────────────────────┼──────────────────────────────────┘ - │ USB - ┌────┴─────┐ - │ RNDIS 设备 │ - └──────────┘ + +------------------------ macOS kernel ------------------------+ + | IP stack / routing / DHCP client | + | | | + | v | + | +---------+ if_fake peer pair +---------+ | + | | feth0 | <------------------> | feth1 | | + | | (system | | (driver | | + | | side) | | side) | | + | +---------+ +----+----+ | + | IP/routing configured | BPF (a single | + | MAC = address reported by device | descriptor for | + | | send and receive) | + +---------------------------------------+---------------------+ + | + +---------------------------------------+---------------------+ + | TetherKit (userspace) v | + | +---------- core::Bridge (3 data-path threads) ---------+ | + | | RX: bulk IN callback -> FrameRing -> batched BPF write| | + | | TX: batched BPF read -> RNDIS aggregation -> bulk OUT | | + | +----------------------+-------------------------------+ | + | +------ rndis::StateMachine (control thread) ----------+ | + | | INITIALIZE / QUERY / SET / KEEPALIVE / RESET / | | + | | HALT / INDICATE_STATUS | | + | +----------------------+-------------------------------+ | + | +------ usb::Device / DataChannel --------------------+ | + | | control channel (EP0 class requests) + interrupt IN | | + | | notification; async bulk IN/OUT transfer pool | | + | +----------------------+-------------------------------+ | + +-------------------------+-----------------------------------+ + | USB + +----+------+ + | RNDIS dev | + +-----------+ ``` -### 模块与依赖方向 +### Modules and dependency direction -依赖**严格单向**,禁止反向或循环: +Dependencies are **strictly one-way**; reverse edges and cycles are forbidden: ``` -tk_common ←(无依赖) 错误、日志、字节序、无锁队列、统计、线程 QoS - ↑ -tk_rndis ← common RNDIS 线格式 + 状态机(**纯逻辑,不碰 I/O**) -tk_net ← common feth 生命周期 + BPF 链路 -tk_usb ← common, rndis libusb 封装(rndis 只用来拿协议常量) - ↑ -tk_core ← 以上全部 数据路径桥接 + 运行时编排 - ↑ -tetherkit ← core 命令行 +tk_common <- (no dependencies) errors, logging, byte order, lock-free queue, statistics, thread QoS + ^ +tk_rndis <- common RNDIS wire format + state machine (**pure logic, no I/O**) +tk_net <- common feth lifetime + BPF link +tk_usb <- common, rndis libusb wrapper (rndis is used only for protocol constants) + ^ +tk_core <- all of the above data-path bridge + runtime orchestration + ^ +tetherkit <- core command line ``` -**`tk_rndis` 不碰 I/O 是刻意的约束**,它让整个 RNDIS 实现(包括状态机)能在 -一台既没有 USB 设备也没有 root 的机器上被完整单元测试 —— 而本项目的开发机正是 -这样一台机器。为此 `ControlChannel` 接口放在 `rndis` 层(它描述的是 RNDIS 协议 -语义,不是 USB 语义),由 `tk_usb` 提供实现。 +**`tk_rndis` not touching I/O is a deliberate constraint.** It lets the entire +RNDIS implementation (including the state machine) be fully unit-tested on a +machine with neither a USB device nor root -- and this project's development +machine is exactly such a machine. For that reason the `ControlChannel` +interface lives in the `rndis` layer (it describes RNDIS protocol semantics, not +USB semantics) and is implemented by `tk_usb`. --- -## 3. 数据流向语义(最关键、也最容易搞错的一点) +## 3. Data-flow semantics (the most important point, and the easiest to get wrong) -已对照 xnu 的 `feth_output_common()` 源码确认: +Confirmed against the xnu `feth_output_common()` source: -| 事件 | 在 feth0 上 | 在 feth1 上 | +| Event | on feth0 | on feth1 | |---|---|---| -| 主机 IP 栈从 feth0 发出一帧 | **OUT** tap | **IN** tap,并作为 input 进入 | -| 我们向 feth1 的 BPF `write()` 一帧 | 作为 **input** 进入 IP 栈 | **OUT** tap | +| the host IP stack sends a frame from feth0 | **OUT** tap | **IN** tap, and enters as input | +| we `write()` a frame to feth1's BPF | enters the IP stack as **input** | **OUT** tap | -由此得到两个结论: +This yields two conclusions: -1. **BPF 只需要挂在 feth1(驱动侧)上,一个描述符同时完成收和发。** -2. **必须设 `BIOCSSEESENT = 0`**(→ `bd_direction = BPF_D_IN`),它恰好滤掉 - 「我们自己 `write` 进去的帧」(那些在 feth1 上是 OUT 方向)。不设就会形成回环, - 自己写的帧立刻被自己读回来。 +1. **BPF only needs to attach to feth1 (the driver side); a single descriptor + handles both receive and send.** +2. **`BIOCSSEESENT = 0` must be set** (-> `bd_direction = BPF_D_IN`). It + filters out exactly the frames we `write` ourselves (they are in the OUT + direction on feth1). Without it a loopback forms and every frame we write is + immediately read back by ourselves. --- -## 4. 并发模型 +## 4. Concurrency model -三个数据路径线程,外加 libusb 自己的 IOKit runloop 线程与状态机的控制线程。 -本机 4 性能核 + 6 效率核,三个热线程 + libusb runloop 刚好占满性能核集群 -且不过订阅。 +Three data-path threads, plus libusb's own IOKit runloop thread and the state +machine's control thread. This machine has 4 performance cores and 6 efficiency +cores; three hot threads plus the libusb runloop exactly fill the performance +cluster without oversubscribing it. -| 线程 | 职责 | 阻塞点 | QoS | +| Thread | Responsibility | Blocking point | QoS | |---|---|---|---| -| `usb-event` | `libusb_handle_events`,跑所有 transfer 回调 | event pipe | USER_INTERACTIVE | -| `rx-inject` | 从 `FrameRing` 批量取帧 → BPF 批量 `write` | 队列空时自旋后 yield | USER_INTERACTIVE | -| `tx-extract` | BPF 阻塞 `read` → RNDIS 聚合 → 异步 bulk OUT | BPF `read()` | USER_INTERACTIVE | -| `rndis-ctl` | 状态机 `Poll()`:保活 + 排空推送 | `sleep` | USER_INITIATED | - -### 为什么 RX 必须拆成两个线程 - -libusb 的 transfer 回调持有 `ctx->event_waiters_lock`,回调里做阻塞 I/O 会把 -**所有等同步传输的线程**(包括跑控制通道的状态机线程)一起拖住。而 BPF 的 -`write()` 是系统调用(约 700 ns 起)。所以回调只做 -「拆 RNDIS 包 + memcpy 进无锁队列 + 立刻 resubmit」,真正的写出交给注入线程。 - -### 为什么 TX 只需要一个线程 - -BPF 的 `read()` 本身就是阻塞且自动聚合的(`BIOCIMMEDIATE=1` 下每来一包就唤醒, -醒来时一次性交付期间累积的全部包),而 `SendFrames` 是**异步**提交、不阻塞。 -「读一批 → 提交一批」在同一个线程里就是最优形态,多一个线程只会多一次跨核传递。 - -### 为什么不用单个 kqueue 事件循环合并三件事 - -调研实测:macOS 上一次空的 `kevent()`(timeout={0,0}、无事件)要 **13.4~13.7 µs**, -比普通系统调用贵 20 倍。25 kpps 下光探测就要吃掉 34% 的单核。 -**kqueue 只能用于真正的阻塞等待,绝不能用于轮询** —— 而我们三条路径各自都已经有 -天然的阻塞点,合并没有收益。 - -### 为什么控制通道必须独占一个非事件线程 - -libusb 的同步 API(`libusb_control_transfer` / `libusb_interrupt_transfer`) -开头就是 `if (usbi_handling_events(ctx)) return LIBUSB_ERROR_BUSY;` —— -这是个 TLS 判断,从事件线程(含任何 transfer 回调内部)调用必然失败。 -所以状态机不能放进事件循环,必须有自己的线程。`main` 把主线程给了它。 - -### 为什么不用 `THREAD_TIME_CONSTRAINT_POLICY` - -它的语义是「每 period 保证 computation 的 CPU」,适合严格周期性的音频渲染线程。 -我们三个线程都长时间阻塞在 `read()` / `write()` / `handle_events()` 上, -设 TC 会(a)清掉线程的 QoS class,(b)超支 computation 时被内核降级。 -另外 macOS **没有** CPU 亲和性 API(`thread_affinity_policy` 在 Apple Silicon 上 -基本无效),所以「绑核」不可行,只能调优先级。 +| `usb-event` | `libusb_handle_events`, runs all transfer callbacks | event pipe | USER_INTERACTIVE | +| `rx-inject` | batch-pull frames from `FrameRing` -> batched BPF `write` | spin then yield when the queue is empty | USER_INTERACTIVE | +| `tx-extract` | blocking BPF `read` -> RNDIS aggregation -> async bulk OUT | BPF `read()` | USER_INTERACTIVE | +| `rndis-ctl` | state-machine `Poll()`: keep-alive + drain indications | `sleep` | USER_INITIATED | + +### Why RX must be split into two threads + +libusb's transfer callback holds `ctx->event_waiters_lock`; doing blocking I/O +inside the callback would stall **every thread waiting on a synchronous +transfer** (including the state-machine thread that runs the control channel). +BPF `write()` is a system call (about 700 ns and up). So the callback only does +"split RNDIS packets + memcpy into the lock-free queue + resubmit immediately"; +the actual write-out is handed to the injection thread. + +### Why TX only needs one thread + +BPF `read()` is itself blocking and auto-batching (with `BIOCIMMEDIATE=1` it +wakes on every incoming packet and delivers all packets accumulated in the +meantime in one go), while `SendFrames` submits **asynchronously** and does not +block. "Read a batch -> submit a batch" in the same thread is the optimal +shape; an extra thread would only add one more cross-core handoff. + +### Why not merge the three into a single kqueue event loop + +Measured in investigation: one empty `kevent()` on macOS (timeout={0,0}, no +events) takes **13.4~13.7 us**, 20 times more expensive than a normal system +call. At 25 kpps, probing alone would eat 34% of one core. **kqueue may only be +used for genuine blocking waits, never for polling** -- and each of our three +paths already has a natural blocking point, so merging them brings no benefit. + +### Why the control channel must have its own non-event thread + +libusb's synchronous APIs (`libusb_control_transfer` / +`libusb_interrupt_transfer`) start with +`if (usbi_handling_events(ctx)) return LIBUSB_ERROR_BUSY;` -- this is a TLS +check and always fails when called from the event thread (including from inside +any transfer callback). So the state machine cannot live in the event loop; it +must have its own thread. `main` gives it the main thread. + +### Why not use `THREAD_TIME_CONSTRAINT_POLICY` + +Its semantics are "guarantee `computation` CPU time every `period`", which fits +strictly periodic audio-rendering threads. All three of our threads block for +long stretches in `read()` / `write()` / `handle_events()`; setting TC would +(a) clear the thread's QoS class and (b) get the thread demoted by the kernel +when it overdraws `computation`. In addition, macOS has **no** CPU affinity API +(`thread_affinity_policy` is effectively useless on Apple Silicon), so "pin to +core" is not feasible and only priority tuning is possible. --- -## 5. 性能设计 +## 5. Performance design -完整数字见 [BENCHMARKS.md](BENCHMARKS.md)。核心结论: +For the full numbers, see [BENCHMARKS.md](BENCHMARKS.md). The core conclusion: -> **优化目标是每帧的系统调用次数,不是 CPU 周期。** +> **The optimization target is system calls per frame, not CPU cycles.** -每帧的 CPU 成本合计约 70 ns(RNDIS 编码 ~15 + 队列搬运 ~55 + 统计 ~0.5), -而一次系统调用约 700 ns —— **系统调用是 CPU 的 10 倍**。macOS 上系统调用比 -Linux 贵 3~10 倍,这是整个设计的第一约束。 +The per-frame CPU cost totals about 70 ns (RNDIS encode ~15 + queue transfer +~55 + statistics ~0.5), while one system call costs about 700 ns -- **a system +call is 10 times the CPU cost**. System calls on macOS are 3-10 times more +expensive than on Linux; this is the first constraint of the whole design. -由此推出四条措施: +Four measures follow: -| 措施 | 机制 | 效果 | +| Measure | Mechanism | Effect | |---|---|---| -| RX 批量写 | `BIOCSBATCHWRITE`(macOS 14+,特性探测,失败回落逐帧) | 一批帧一次系统调用 | -| TX 多包聚合 | RNDIS 允许一次 bulk OUT 承载多个 `PACKET_MSG` | 一批帧一次 USB 往返 | -| 让设备也聚合 | `INITIALIZE_MSG` 里把 `MaxTransferSize` 报大(默认 16 KiB) | **RX 吞吐的主要杠杆** | -| 批量发布队列 | `FrameRing::BatchWrite/BatchRead`,一批只做一次 release store | 1514 字节帧提速 2.4 倍,64 字节 5.0 倍 | +| RX batched writes | `BIOCSBATCHWRITE` (macOS 14+, probed at runtime, falls back to per-frame writes on failure) | one system call per batch of frames | +| TX multi-packet aggregation | RNDIS allows one bulk OUT to carry multiple `PACKET_MSG`s | one USB round trip per batch of frames | +| make the device aggregate too | report a large `MaxTransferSize` in `INITIALIZE_MSG` (16 KiB by default) | **the main lever for RX throughput** | +| batch-publish queue | `FrameRing::BatchWrite/BatchRead`, one release store per batch | 1514-byte frames 2.4x faster, 64-byte frames 5.0x faster | -### 为什么只拷贝一次、而不追求零拷贝 +### Why copy once, not zero-copy -RX 路径每帧一次 memcpy(USB 缓冲 → `FrameRing` 槽位)。追求零拷贝要求把 USB -传输缓冲交给下游持有,就不能立刻 resubmit,得准备远多于「在飞传输数」的缓冲 -并引入归还机制 —— 而 memcpy 1514 字节只要 25~70 ns,是系统调用的 1/10。 -**为它做零拷贝是错误的优化方向。** +The RX path does one memcpy per frame (USB buffer -> `FrameRing` slot). +Zero-copy would require handing the USB transfer buffer to the downstream +consumer, which means it cannot be resubmitted immediately; that would require +far more buffers than "in-flight transfers" and a return mechanism -- but +memcpy of 1514 bytes takes only 25-70 ns, 1/10 of a system call. **Pursuing +zero-copy for it is the wrong optimization direction.** -TX 方向的零拷贝更是**根本不可行**:`struct bpf_hdr` 只有 20 字节,而 RNDIS 包头 -需要 44 字节,帧前空间差 24 字节;feth 的 `tx_headroom` 是 32 字节,同样不够。 +Zero-copy in the TX direction is **fundamentally impossible**: `struct bpf_hdr` +is only 20 bytes while an RNDIS packet header needs 44, a 24-byte shortfall in +headroom; feth's `tx_headroom` is 32 bytes, also not enough. -### 缓存行是 128 字节 +### The cache line is 128 bytes -`sysctl hw.cachelinesize` 报 **128**(不是习惯性的 64)。按 64 对齐的话, -生产者与消费者的索引仍会落在同一条 128 字节缓存行上,false sharing 依然存在。 +`sysctl hw.cachelinesize` reports **128** (not the habitual 64). Aligning to 64 +would leave the producer and consumer indices on the same 128-byte cache line +and false sharing would still be present. -刻意**不用** `std::hardware_destructive_interference_size`:它在 Apple libc++ 上 -确实存在(在 `` 里),但报的是 **256**,与真实的 128 不符 —— 用它会把所有 -填充翻倍。 +`std::hardware_destructive_interference_size` is **intentionally not used**: it +does exist in Apple libc++ (in ``), but reports **256**, not the actual +128 -- using it would double every padding. --- -## 6. 错误处理 +## 6. Error handling -| 路径 | 手段 | 理由 | +| Path | Mechanism | Rationale | |---|---|---| -| 初始化 / 控制 | `std::expected` | 失败绝大多数是「预期内的环境问题」(没插设备、没有 root、接口被占),调用方总要处理,不该靠异常 | -| **数据热路径** | **返回计数 + 原子计数器** | `Error` 里的 `std::string` 会分配堆内存,25k~80k pps 下不可接受 | +| initialization / control | `std::expected` | most failures are "expected environment conditions" (no device plugged in, no root, interface busy); the caller always has to handle them, so exceptions are the wrong tool | +| **data hot path** | **return counts + atomic counters** | the `std::string` inside `Error` allocates heap memory, which is unacceptable at 25k-80k pps | -`Error` 携带来源域(errno / libusb / RNDIS_STATUS / 逻辑),`ToString()` 负责把 -「libusb 返回 -3」翻译成「LIBUSB_ERROR_ACCESS(-3)」。`WithContext` 支持叠加外层 -原因形成「外层:内层」的链条 —— 那个分隔符随语言变化(中文全角冒号、英文 -`": "`),所以由 `detail::ContextSeparator()` 提供,不写死在 `error.h` 里。 +`Error` carries a source domain (errno / libusb / RNDIS_STATUS / logical), and +`ToString()` is responsible for translating "libusb returned -3" into +`LIBUSB_ERROR_ACCESS(-3)`. `WithContext` can layer an outer reason to form an +"outer: inner" chain -- that separator comes from `detail::ContextSeparator()` +instead of being hard-coded in `error.h`, so the formatting stays consistent +with the active message table. -错误消息本身全部来自文案表(见第 6b 节),不是字面量。 +The error messages themselves all come from the message table (see section 6b), +not from string literals. -**RNDIS 状态码到名字的映射只存在于 `rndis/protocol.cc` 一处**(单一来源); -`tk_common` 不认识 RNDIS,只输出十六进制数值,符号名由 rndis 层拼进上下文串。 -(此前在 `error.cc` 里也放了一份,5 个数值是错的,已删除。) +**The RNDIS status-code-to-name mapping lives in exactly one place, +`rndis/protocol.cc`** (a single source of truth); `tk_common` does not know +RNDIS and only outputs the hexadecimal value, while the symbolic name is +spliced into the context string by the rndis layer. (There used to be a second +copy in `error.cc` with 5 wrong values; it has been removed.) --- -## 6b. 文案与多语言 +## 6b. User-facing strings and the multi-language plumbing -面向用户的文字(错误、日志、帮助、状态名)**一条都不写字面量**,全部集中在 -`include/tetherkit/common/messages.def`。该文件是一份 X-macro 清单,被展开三次 -分别生成 `Msg` 枚举、中文表与英文表 —— 三者同源,**结构上不可能出现某种语言 -漏了一条**。 +User-facing text (errors, logs, help, status names) **is never written as a +string literal**; it all lives in +`include/tetherkit/common/messages.def`. That file is an X-macro list expanded +to generate the `Msg` enum and the English string table -- all from the same +source, so **a message cannot be structurally missing from the table**. The +project is currently English-only, but the multi-language plumbing (the +`Language` enum, `TextIn`, `ParseLanguage`, the C ABI `tk_set_language`, the +helper's `setLanguage` XPC method, and `L10n.apply`) is deliberately kept so +that adding a language later stays a bounded change; the checklist for that +change is at the top of `include/tetherkit/common/messages.def`. -| 取用方式 | 用途 | 是否分配 | +| Access form | Use | Allocates? | |---|---|---| -| `Tr(Msg::kFoo, args...)` | 带参数,语义同 `std::format` | 会(返回 `std::string`) | -| `Text(Msg::kFoo)` | 不带参数,返回 `string_view` | 不会,`noexcept` | -| `TETHERKIT_INFO_TR(Msg::kFoo, ...)` 等 | 打日志 | 级别没开时不求值 | - -**为什么不用 gettext**:要引入 libintl、构建期跑 msgfmt、运行期按路径查目录, -换来的是「装到别的机器上找不到 `.mo` 于是全变英文」这类运行期故障。两种语言、 -几百条固定文案,编译进二进制的一张表最省事。 - -**代价与对策**:格式串变成运行期查表,`std::format` 的编译期占位符校验就没了 -(`Tr` 内部走 `vformat`)。这道保障由 `tests/test_common_i18n.cc` 补回来 —— -它遍历整张表,逐条比对两种语言引用的参数下标与表现类型,并检查下标连续、 -未在同一串里混用自动与手工编号。占位符写错会在 `ctest -R common.i18n` 当场红掉, -而不是等到某条日志在用户机器上渲染成半句话。 - -`Tr()` 和 `error.h` 一样**禁止出现在数据热路径上**(它必然分配)。热路径需要 -文字时只能用 `Text()`。 - -语言由宿主决定:命令行看 `--lang` 与环境变量,GUI 通过 C ABI 的 -`tk_set_language` 推进来。这是**进程级单一状态**,不是每个会话一份 —— 日志与 -错误从多个线程产生,做成线程局部只会让同一次会话的输出出现两种语言。 +| `Tr(Msg::kFoo, args...)` | with arguments, same semantics as `std::format` | yes (returns `std::string`) | +| `Text(Msg::kFoo)` | no arguments, returns `string_view` | no, `noexcept` | +| `TETHERKIT_INFO_TR(Msg::kFoo, ...)` etc. | logging | not evaluated when the level is off | + +**Why not gettext**: it would mean pulling in libintl, running msgfmt at build +time, and looking up directories at runtime, in exchange for runtime failures of +the "installed on another machine, cannot find `.mo`, so everything turns into +English" kind. For a fixed set of a few hundred messages, a table compiled into +the binary is the simplest choice. + +**Cost and mitigation**: format strings become runtime lookups, so +`std::format`'s compile-time placeholder checks are gone (`Tr` goes through +`vformat` internally). That guarantee is provided by +`tests/test_common_i18n.cc` instead -- it walks the whole table and checks each +message for well-formed placeholders, consistent argument indexing and types, +contiguous indices, and that automatic and manual numbering are not mixed in the +same string. A placeholder mistake fails `ctest -R common.i18n` immediately +instead of rendering as a half-formed log line on a user's machine. + +`Tr()` is **forbidden on the data hot path**, for the same reason as `error.h` +(it always allocates). Hot-path code that needs text may only use `Text()`. + +The language is decided by the host: the command line takes `--lang en` (and +reads environment variables for defaults where applicable), and the GUI passes +its choice through the C ABI `tk_set_language`. The GUI language menu offers +"Follow system" and "English". This is a **single process-wide state**, not one +per session -- logs and errors are produced from multiple threads, and making it +thread-local would let a single session emit output in two different languages. --- -## 7. 背压策略 +## 7. Backpressure strategy -| 位置 | 满了怎么办 | 理由 | +| Location | What if full | Rationale | |---|---|---| -| RX 队列(USB → BPF) | **丢弃并计数** | libusb 回调里不能阻塞(持锁),只能丢 | -| TX 传输池(BPF → USB) | **丢弃并计入背压事件** | 等待会让 BPF 内核缓冲堆积,最终由内核丢 —— 那是运维**看不见**的丢包。在用户态丢并计数才能定位瓶颈。TCP 会重传,UDP 本来允许丢 | -| 暂停期间的 RX | **不丢**,队列里的帧等恢复后继续送 | 链路只是暂时 down,帧仍然有效 | -| 暂停期间的 TX | **丢弃并计数** | RNDIS 软复位期间设备会丢弃所有未完成的数据包,攒着只是浪费内存 | +| RX queue (USB -> BPF) | **drop and count** | the libusb callback cannot block (it holds a lock); dropping is the only option | +| TX transfer pool (BPF -> USB) | **drop and count as backpressure** | waiting would let the BPF kernel buffer accumulate and eventually be dropped by the kernel -- drops that are **invisible** to operators. Dropping in userspace and counting makes the bottleneck visible. TCP will retransmit; UDP tolerates drops by design | +| RX while paused | **do not drop**; frames already in the queue go through after resume | the link is only temporarily down; the frames are still valid | +| TX while paused | **drop and count** | during an RNDIS soft reset the device drops all in-flight packets; buffering them would only waste memory | --- -## 8. 拆除顺序(避免 use-after-free) +## 8. Teardown order (to avoid use-after-free) -`libusb_close` **不会**回收在飞 transfer —— 它只是 `list_del` + 把 -`dev_handle` 置空并打一条日志,**不调回调、不释放内存**;之后 IOKit 中止仍会让 -`darwin_async_io_callback` 在 libusb 内部线程上跑。若那时已 `free` 就是 UAF。 -这是 libusb 用法里最常见的崩溃源。 +`libusb_close` **does not** reclaim in-flight transfers -- it only does +`list_del`, clears `dev_handle`, and logs; it does **not** call callbacks and +does **not** free memory. After that, IOKit aborts can still cause +`darwin_async_io_callback` to run on a libusb internal thread. If memory has +been freed by then, that is a use-after-free. This is one of the most common +libusb pitfalls. -正确顺序(`Runtime::Stop()` 与 `UsbDataChannel::Shutdown()` 共同实现): +Correct order (implemented jointly by `Runtime::Stop()` and +`UsbDataChannel::Shutdown()`): ``` 1. Bridge::Stop() - ├ 置停机标志 - ├ link->Interrupt() 打断 BPF 阻塞读 + ├ set stop flag + ├ link->Interrupt() interrupt blocking BPF read ├ join rx-inject / tx-extract └ DataChannel::Shutdown() - ├ 置停机标志(回调不再 resubmit) - ├ cancel 全部在飞 transfer - ├ **等在飞计数归零**(每个回调都回来了) - └ 才 libusb_free_transfer + 释放缓冲 -2. 关 BPF -3. 销毁 feth 网卡对 -4. 状态机 Stop():SET filter=0 → HALT -5. 释放 USB 接口、关闭句柄 -6. 停 libusb 事件线程、libusb_exit + ├ set stop flag (callbacks must not resubmit) + ├ cancel all in-flight transfers + ├ **wait for in-flight count to reach zero** (every callback has returned) + └ only then libusb_free_transfer + free buffers +2. close BPF +3. destroy the feth pair +4. state machine Stop(): SET filter=0 -> HALT +5. release USB interfaces, close handle +6. stop libusb event thread, libusb_exit ``` -两个必须记住的约束: +Two constraints to remember: -- **第 1 步里「等在飞计数归零」绝不能在 libusb 事件线程上等** —— 递减它的回调 - 正是在那个线程上跑的,在那里等就是自己等自己,必然死锁。 -- 等待超时时**故意泄漏**内存而不是冒险释放。泄漏几百 KB 远好于 UAF 崩溃, - 并打出明确的错误日志说明这一决定。 +- **In step 1, "wait for the in-flight count to reach zero" must never run on + the libusb event thread** -- the callbacks that decrement it run on that same + thread, so waiting there would deadlock. +- On timeout, **leak memory deliberately rather than risk freeing it**. Leaking + a few hundred KB is far better than a use-after-free crash; a clear error log + must explain the decision. --- -## 9. 可测试性 +## 9. Testability -开发机既**没有 USB 设备**(`libusb_get_device_list` 返回 0)也**没有 root**。 -因此每个需要外部资源的边界都做了接口抽象: +The development machine has **neither a USB device** (`libusb_get_device_list` +returns 0) nor **root**. For that reason, every boundary that needs an external +resource is abstracted behind an interface: -| 接口 | 生产实现 | 测试实现 | +| Interface | Production implementation | Test implementation | |---|---|---| | `rndis::ControlChannel` | `usb::UsbControlChannel` | `testing::MockControlChannel` | | `usb::DataChannel` | `usb::UsbDataChannel` | `testing::MockDataChannel` | | `net::LinkBackend` | `net::BpfLink` | `net::LoopbackLink` | -**抽象的粒度是「批」而不是「帧」**:一次虚调用处理几十上百帧,摊到每帧的开销 -远小于 1 ns。如果做成每帧一次虚调用就不可接受了 —— 那是刻意避开的设计。 +**The abstraction granularity is "batch", not "frame"**: one virtual call +processes dozens or hundreds of frames, so the per-frame overhead is far below +1 ns. A virtual call per frame would be unacceptable -- that design is +deliberately avoided. -于是状态机的全部路径(含设备插队推送、保活失败、复位重放)、桥接层的全部路径 -(含双向并发、背压、暂停、有流量时停机)都能离线测试,并在 ThreadSanitizer 下 -验证。需要 root 的 feth/BPF 用例默认**跳过而非失败**,用 -`TETHERKIT_ROOT_TESTS=1` 显式开启。 +This lets every state-machine path (including device-queued indications, +keep-alive failures, and reset replay) and every bridge path (including +bidirectional concurrency, backpressure, pause, and shutdown under traffic) be +tested offline and verified under ThreadSanitizer. feth/BPF cases that need +root are **skipped rather than failed** by default and can be enabled explicitly +with `TETHERKIT_ROOT_TESTS=1`. --- -## 10. 私有 ABI 的使用与风险 +## 10. Use and risk of private ABIs -本项目用到三处不在公开 SDK 里的东西: +The project uses three things that are not in the public SDK: -| 私有 ABI | 用途 | 风险与对策 | +| Private ABI | Purpose | Risk and mitigation | |---|---|---| -| `struct ifdrv` | `SIOCSDRVSPEC` 的参数 | 大小参与 ioctl 编号计算,算错只会得到不存在的 ioctl(返回 `ENOTTY`,不会做危险的事)。用 `static_assert` 把大小=40、各字段偏移、以及推导出的 `SIOCSDRVSPEC=0x8028697b` 全部钉死 | -| `struct if_fake_request` | feth peer 配对 | 该文件在 xnu-7195(macOS 11) → xnu-12377(macOS 26) 的所有发布 tag 下内容完全相同,且 Apple 自己的 `ifconfig fethN peer fethM` 就依赖它 —— 实际上已被冻结。同样有 `static_assert`(大小=160) | -| `BIOCSBATCHWRITE` / `BIOCSNOTSTAMP` | 性能优化 | **纯可选**。一律做运行时特性探测,失败回落到通用路径,不影响功能正确性 | +| `struct ifdrv` | parameter to `SIOCSDRVSPEC` | its size participates in the ioctl number calculation; a wrong size would only yield a non-existent ioctl (returning `ENOTTY`, never doing anything dangerous). `static_assert` pins size=40, every field offset, and the derived `SIOCSDRVSPEC=0x8028697b` | +| `struct if_fake_request` | feth peer pairing | this file is byte-identical across every published tag from xnu-7195 (macOS 11) to xnu-12377 (macOS 26), and Apple's own `ifconfig fethN peer fethM` depends on it -- in practice it is frozen. Also pinned by `static_assert` (size=160) | +| `BIOCSBATCHWRITE` / `BIOCSNOTSTAMP` | performance optimizations | **purely optional**. Always probed at runtime; on failure the code falls back to the generic path without affecting correctness | -一句话:**功能性 ABI 经过 15 年验证且有 `static_assert` 兜底;优化性 ABI 做特性 -探测。两者都不会静默走错路径。** +In one sentence: **functional ABIs have 15 years of validation and are backed by +`static_assert`; optimization ABIs are probed at runtime. Neither can silently +take the wrong path.** --- -## 11. 代码规范 +## 11. Code conventions -| 类别 | 约定 | 例 | +| Category | Convention | Example | |---|---|---| -| 命名空间 | `lower_case` | `tetherkit::rndis` | -| 类型 | `CamelCase` | `PacketMessageWriter` | -| 函数与方法(含访问器) | `CamelCase` | `MaxFrameBytes()` | -| 局部变量与参数 | `lower_case` | `frame_length` | -| 私有成员 | `lower_case_` | `bulk_in_endpoint_` | -| 常量 / 枚举值 | `k` + `CamelCase` | `kPacketMsgHeaderBytes` | -| 文件 | `snake_case`,`.h` / `.cc` | `packet_codec.h` | -| 文案标识 | `k` + 模块前缀 + `CamelCase` | `kNetBpfBindFailed`、`kCliUnknownOption` | - -访问器也用 `CamelCase`(而非标准库风格的 `lower_case`),是为了让 -`readability-identifier-naming` 能机械地全量检查,不留「凭记忆遵守」的例外。 - -`.clang-tidy` 里关掉了一批对底层系统编程不适用的检查,每一条都写了理由。 -特别是 `performance-enum-size` 必须关掉 —— 本项目的协议枚举底层类型**必须** -精确等于线格式的字段宽度(RNDIS 全是 LE32),按它的建议缩小会直接写坏线格式。 +| namespace | `lower_case` | `tetherkit::rndis` | +| type | `CamelCase` | `PacketMessageWriter` | +| function and method (including accessors) | `CamelCase` | `MaxFrameBytes()` | +| local variable and parameter | `lower_case` | `frame_length` | +| private member | `lower_case_` | `bulk_in_endpoint_` | +| constant / enum value | `k` + `CamelCase` | `kPacketMsgHeaderBytes` | +| file | `snake_case`, `.h` / `.cc` | `packet_codec.h` | +| message identifier | `k` + module prefix + `CamelCase` | `kNetBpfBindFailed`, `kCliUnknownOption` | + +Accessors also use `CamelCase` (not the standard-library `lower_case` style) so +that `readability-identifier-naming` can check everything mechanically, with no +exceptions left to memory. + +`.clang-tidy` disables a number of checks that do not fit low-level systems +programming, and every one of them has a stated reason. In particular, +`performance-enum-size` must stay off -- this project's protocol enums **must** +have an underlying type that exactly matches the wire field width (RNDIS is all +LE32); shrinking them as the check suggests would corrupt the wire format. diff --git a/docs/GUI-ARCHITECTURE.md b/docs/GUI-ARCHITECTURE.md index c78286c..9ec64ea 100644 --- a/docs/GUI-ARCHITECTURE.md +++ b/docs/GUI-ARCHITECTURE.md @@ -1,560 +1,794 @@ -# GUI 实现备忘 +# GUI implementation notes -本文记录 TetherKit 图形界面**已经实现了什么、为什么这么实现、哪些地方碰不得**。 +This document records what the TetherKit graphical interface **has +implemented, why it is implemented that way, and which parts must not be +touched**. -配套阅读:[GUI-SPIKE.md](GUI-SPIKE.md) 记录的是动手之前的可行性验证(尤其是被 -排除的三条路线);本文记录的是落地之后的结构与约束。**改 GUI 相关代码前先读 -这两篇**,能省掉重新踩一遍坑的时间。 +Companion reading: [GUI-SPIKE.md](GUI-SPIKE.md) records the feasibility spike +from before the work started (in particular the three routes that were ruled +out); this document records the structure and the constraints after it landed. +**Read both of them before changing any GUI code** -- it saves re-learning the +pitfalls the hard way. --- -## 1. 进程与信任模型 +## 1. Process and trust model ``` -┌─────────────────────────────┐ ┌──────────────────────────────┐ -│ TetherKit.app (uid 501) │ │ tetherkit-helper (uid 0) │ -│ │ XPC │ │ -│ · SwiftUI 界面 │ ──────► │ · 持有 RNDIS 会话 │ -│ · 弹系统授权框取凭据 │ │ · 建/销毁 feth、开 BPF │ -│ · 定时拉状态刷界面 │ ◄────── │ · 配 IP(ipconfig / route) │ -│ · 不碰 USB、不碰网卡 │ │ · 复核每次调用的凭据 │ -└─────────────────────────────┘ └──────────────────────────────┘ - ▲ - │ 按需拉起(MachServices) - ┌─────┴──────┐ - │ launchd │ - └────────────┘ ++---------------------------------------------------+ +-------------------------------------------+ +| TetherKit.app (uid 501) | | tetherkit-helper (uid 0) | +| | XPC | | +| - SwiftUI interface | ------> | - holds the RNDIS session | +| - pops the system auth dialog for a credential | | - creates/destroys feth, opens BPF | +| - polls the state on a timer to refresh the UI | <------ | - configures the IP (ipconfig / route) | +| - never touches USB, never touches the interface | | - re-checks the credential on every call | ++---------------------------------------------------+ +-------------------------------------------+ + ^ + | started on demand (MachServices) + +-----+------+ + | launchd | + +------------+ ``` -**helper 的 root 来自 launchd,跟用户按没按指纹毫无关系。** 它一启动就是 root, -任何能连上 Mach 服务的进程都能发请求。所以「谁在调用」必须由 helper 自己回答 —— -每个特权方法都要求附带一份 App 刚拿到的 `AuthorizationRef` 外部形式,并当场复核。 - -安全性建立在**凭据复核**而不是**代码签名校验**上。后者(SMJobBless 的做法)依赖 -证书的 designated requirement,而源码分发下每台机器编出的 cdhash 都不同,写死的 -DR 必然对不上。凭据复核对开源工具是更合适的模型:谁编译的都一样安全。 - -> **一句必须记住的话:授权 ≠ root。** -> `AuthorizationCopyRights` 成功不改变进程的任何东西,uid / euid 一个都没动。 -> 它只产出一张「用户在某时刻确认过」的凭据。顺序不能反:不是「先弹框拿到 -> root」,而是「先有常驻的 root helper,弹框拿到凭据后交给它复核」。 +**The helper's root comes from launchd and has nothing to do with whether the +user touched their fingerprint.** It is root from the moment it starts, and any +process that can reach the Mach service can send requests. So "who is calling" +has to be answered by the helper itself -- every privileged method requires the +external form of the `AuthorizationRef` the App just obtained, and the helper +re-checks it on the spot. + +Security rests on **credential re-checking**, not on **code-signature +verification**. The latter (the SMJobBless approach) depends on the +certificate's designated requirement, and under source distribution the cdhash +produced on each machine differs, so a hard-coded DR can never match. Credential +re-checking is the better model for an open-source tool: whoever built it, it +is equally safe. + +> **One sentence to remember: authorization != root.** +> A successful `AuthorizationCopyRights` changes nothing about the process; not +> one of uid / euid moves. It only produces a credential that says "the user +> confirmed at some moment". The order cannot be reversed: it is not "pop the +> dialog and get root", but "have a resident root helper first, pop the dialog, +> then hand the credential to it for re-checking". --- -## 2. 目录结构 +## 2. Directory layout ``` -include/tetherkit/capi/tetherkit_c.h 全项目唯一的 extern "C" 边界 -src/capi/ C ABI 实现 -├── capi_support.{h,cc} 定长缓冲拷贝、错误翻译、网卡名校验 -├── core_foundation_support.{h,cc} CF / SCDynamicStore 的最小 RAII 封装 -├── environment.cc 版本、环境预检、设备枚举(免 root) -├── log_ring.cc 日志环形缓冲 -├── net_config.cc DHCP / 静态 IP / 状态回读 -├── orphan_cleanup.cc feth 落盘登记与孤儿清理 -├── process_runner.{h,cc} posix_spawn 执行外部工具(不过 shell) -└── session.cc 会话生命周期、状态快照、事件轮询 +include/tetherkit/capi/tetherkit_c.h the only extern "C" boundary in the project +src/capi/ C ABI implementation +|-- capi_support.{h,cc} fixed-size buffer copies, error translation, interface name checks +|-- core_foundation_support.{h,cc} minimal RAII wrappers for CF / SCDynamicStore +|-- environment.cc version, environment preflight, device enumeration (no root needed) +|-- log_ring.cc log ring buffer +|-- net_config.cc DHCP / static IP / status read-back +|-- orphan_cleanup.cc on-disk feth registry and orphan cleanup +|-- process_runner.{h,cc} runs external tools through posix_spawn (never through a shell) +`-- session.cc session lifecycle, status snapshots, event polling gui/ -├── Package.swift SwiftPM 工程 -├── Sources/ -│ ├── CTetherKit/ C ABI 的模块映射(头是符号链接) -│ ├── TetherKitIPC/ App 与 helper 共享:协议、模型、授权、文案表 -│ ├── TetherKitCore/ C ABI 的 Swift 封装 -│ ├── TetherKitHelper/ 特权 helper -│ ├── TetherKitUIModel/ 主窗口页面模型(SidebarPage:固定取色与 -│ │ 会话状态取色、持久化选择解析)—— 独立 -│ │ library 是为了可单测(可执行 target 不能 -│ │ 被 @testable import) -│ └── TetherKitApp/ SwiftUI 界面(含主窗口管理与各页面视图) -├── Tests/TetherKitIPCTests/ 授权凭据生命周期 + 文案表占位符一致性 -├── Tests/TetherKitUIModelTests/ 页面 tint 语义 + 持久化选择解析 -├── Resources/ Info.plist、LaunchDaemon plist -└── Scripts/ 构建 / 安装 / 卸载脚本 +|-- Package.swift SwiftPM project +|-- Sources/ +| |-- CTetherKit/ module map for the C ABI (the headers are symlinks) +| |-- TetherKitIPC/ shared by the App and the helper: protocol, models, authorization, string table +| |-- TetherKitCore/ Swift wrapper of the C ABI +| |-- TetherKitHelper/ privileged helper +| |-- TetherKitUIModel/ main window page model (SidebarPage: fixed colors and +| | session state colors, persisted selection parsing) -- a +| | separate library so that it can be unit tested (an executable +| | target cannot be @testable imported) +| `-- TetherKitApp/ SwiftUI interface (main window management and the page views) +|-- Tests/TetherKitIPCTests/ authorization credential lifecycle + string table placeholder consistency +|-- Tests/TetherKitUIModelTests/ page tint semantics + persisted selection parsing +|-- Resources/ Info.plist, LaunchDaemon plist +`-- Scripts/ build / install / uninstall scripts ``` --- -## 3. 数据流 +## 3. Data flow -| 方向 | 机制 | 周期 | +| Direction | Mechanism | Period | |---|---|---| -| 库 → helper:状态 | `tk_session_status_get` 拷贝快照 | 按需 | -| 库 → helper:事件 | `tk_session_poll_events` 取环形队列 | 随状态查询搭车 | -| 库 → helper:日志 | `tk_drain_logs` 取环形队列 | 每次 `drainFeed` | -| helper → App | XPC,JSON 编码的 Codable | 500 ms | - -**全链路没有一个回调穿过语言边界。** 这是刻意的:底层的回调会从 libusb 事件 -线程与控制线程上来,跨语言要 marshal;更要命的是重入 —— 在回调里调停机会自等 -死锁(`Stop()` 要 join 控制线程)。一律改成「库内排队、宿主轮询」。 +| library -> helper: status | `tk_session_status_get` copies a snapshot | on demand | +| library -> helper: events | `tk_session_poll_events` takes from the ring queue | rides along with the status query | +| library -> helper: logs | `tk_drain_logs` takes from the ring queue | every `drainFeed` | +| helper -> App | XPC, JSON-encoded Codable | 500 ms | + +**Not a single callback crosses the language boundary anywhere in the chain.** +This is deliberate: the low-level callbacks arrive from the libusb event +thread and from the control thread, so crossing languages means marshalling; +worse is re-entrancy -- calling stop from inside a callback self-waits into a +deadlock (`Stop()` joins the control thread). Everything is changed to "queue +inside the library, the host polls". --- -## 4. 关键实现约束(改代码时别破坏这些) +## 4. Key implementation constraints (do not break these when changing the code) ### 4.1 C ABI -- **不跨边界传所有权。** 输出一律写进调用方提供的定长结构体。唯一例外是 - `tk_session_t*`,创建 / 销毁配对明确。 -- **定长缓冲的截断必须落在 UTF-8 字符边界上。** 错误消息全是中文,按字节硬切 - 会让 Swift 侧 `String(cString:)` 整串变成替换字符。两个方向都要管:C 侧是 - `capi_support.h` 的 `CopyText`,Swift 侧是 `CInterop.swift`。 -- **C 枚举与 C++ 枚举靠顺序一致直接强转**,`session.cc` 里有一组 - `static_assert` 把它焊死。中间插入一个枚举值会静默错位,且毫无报错。 -- **`tk_session` 的成员声明顺序有约束**:`events` 必须在 `runtime` 之前, - 销毁时 `runtime` 才会先 join 控制线程。反过来就是 use-after-free。 - -### 4.2 Runtime 线程模型 - -`Runtime` 自己拥有一条控制线程,**启动序列、保活循环、停机拆除全部在它上面跑**。 -这不只是为了让 `Start()` 非阻塞,更是把 `StateMachine` 的「所有方法同线程调用」 -从口头约定变成结构性保证 —— 优雅停机要发同步控制消息,而 libusb 的同步 API 在 -事件线程上会返回 `LIBUSB_ERROR_BUSY`。 - -宿主线程**只**通过 `Snapshot()` 读状态,绝不碰那些组件指针 —— 否则 `Stop()` 里的 -`reset()` 与宿主的读会构成数据竞争,表现为随机崩溃。 - -`RequestStop()` 保持只写一个原子(异步信号安全),代价是最多晚一个循环周期 -(≤250 ms)被看到;`Stop()` 额外用条件变量唤醒控制线程,所以界面上按「断开」 -是立刻响应的。 - -### 4.3 网卡配置 - -- **地址一律经 `ipconfig` 下发,不要改回裸 `SIOCAIFADDR`。** 裸 ioctl 配出来的 - 地址内核认、configd 不认 —— 不会有服务、不会有 scoped DNS、不会有默认路由。 - 这一条已在 GUI-SPIKE 第 3.2 / 6.3 节实测确认。 -- **外部工具用 `posix_spawn` 传 argv 数组,不过 shell。** 参数虽然都校验过, - 但只要经过 `/bin/sh`,防线就依赖「校验有没有漏」这一条。 -- **先读空管道再 `waitpid`。** 反过来是经典的 pipe 死锁:子进程写满 64 KiB - 缓冲后阻塞在 `write`,我们阻塞在 `waitpid`。`process_runner.cc` 有对应测试。 -- **网卡名只接受 `feth<数字>`。** 这不是洁癖 —— 挡的是「误把 en0 传进来, - 把用户的 Wi-Fi 配置冲掉」这类事故。 -- **`SCDynamicStore` 句柄必须是进程级长命的。** 动态存储里的值由设置它的会话 - 持有,会话一释放值就没了。用临时 store 会出现「写入返回成功、读回来是空」 - 这种极难查的现象。 - -### 4.4 授权复核(`TetherKitIPC/Authorization.swift`) - -三条一旦写错就是漏洞的细节,都写在那个文件的注释里: - -1. helper 侧复核时**绝不能带 `.interactionAllowed`** —— daemon 没有 UI 会话, - 真让它能弹框,等于任何进程都能随意触发系统授权框骚扰用户。 -2. **`AuthorizationFree` 不能带 `.destroyRights`** —— 凭据归 App 所有,helper - 只是借来核对,带上会把 App 那边的授权一起作废。 -3. **探测类方法不要求授权** —— 否则「helper 没装」和「授权没过」两种失败会混在 - 一起,没法给用户准确提示。 - -取凭据(App 侧,**要**带 `.interactionAllowed`)与复核(helper 侧,**不**带) -刻意写在同一个文件里:它们的差别只有一个标志位,改一边时另一边就在眼前。 - -第四条,**实际踩过一次**: - -4. **`AuthorizationMakeExternalForm` 产出的 32 字节不是凭据本身,只是指向 - securityd 里那份授权的一把钥匙。** App 侧一旦在 XPC 往返完成**之前**释放 - `AuthorizationRef`(尤其是带 `.destroyRights`),helper 还原时就会失败, - 报 `errAuthorizationDenied (-60005)`。 - - 而这个错误码从代码上完全看不出「是被自己提前销毁的」—— 它看起来像是用户 - 授权被拒。所以凭据由 `AuthorizationToken` 持有、`AuthorizationBroker - .withAuthorization` 用 `withExtendedLifetime` 保证它活到调用结束。 - **不要裸接住 `requestAuthorization()` 的返回值再用** —— ARC 完全可以在最后 - 一次读 `externalForm` 之后就把令牌释放掉。 - - `gui/Tests/TetherKitIPCTests/AuthorizationTests.swift` 把这个行为钉住了, - 两条用例都不弹授权框,可以进 CI。 - -**令牌必须缓存复用,否则每个操作都要用户重新认证一次。** -`system.privilege.admin` 的实测参数(`security authorizationdb read`): - -| 字段 | 值 | 含义 | +- **No ownership crosses the boundary.** Output always goes into the + fixed-length struct the caller provides. The only exception is `tk_session_t*`, + whose create / destroy pairing is explicit. +- **Truncation of a fixed-length buffer must land on a UTF-8 character + boundary.** The error messages can contain non-ASCII characters, and cutting + them hard at a byte boundary turns the whole `String(cString:)` on the Swift + side into replacement characters. Both directions need care: on the C side it + is `CopyText` in `capi_support.h`, on the Swift side it is `CInterop.swift`. +- **The C enums and the C++ enums are cast directly on the strength of a + matching order**, and a set of `static_assert`s in `session.cc` welds that + down. Inserting an enum value in the middle shifts them silently, with no + error at all. +- **The member declaration order of `tk_session` is constrained**: `events` must + come before `runtime`, so that on destruction `runtime` joins the control + thread first. The other way round is a use-after-free. + +### 4.2 Runtime threading model + +`Runtime` owns its own control thread; **the start sequence, the keep-alive +loop and the shutdown teardown all run on it**. This is not just about making +`Start()` non-blocking; it turns the `StateMachine`'s "every method is called from +the same thread" from a verbal convention into a structural guarantee -- a +graceful shutdown has to send a synchronous control message, and libusb's +synchronous API returns `LIBUSB_ERROR_BUSY` when it is called from the event +thread. + +The host thread reads the state **only** through `Snapshot()` and never touches +those component pointers -- otherwise the `reset()` inside `Stop()` and the +host's reads form a data race, which shows up as random crashes. + +`RequestStop()` stays a single atomic write (async-signal-safe), at the cost of +being seen up to one loop period late (<=250 ms); `Stop()` additionally wakes +the control thread with a condition variable, so pressing "Disconnect" in the +interface responds immediately. + +### 4.3 Interface configuration + +- **Always push the addresses through `ipconfig`; do not go back to a bare + `SIOCAIFADDR`.** An address configured with the bare ioctl is accepted by + the kernel but not by configd -- no service, no scoped DNS, no default route. + This was measured and confirmed in sections 3.2 / 6.3 of GUI-SPIKE. +- **Run the external tools through `posix_spawn` with an argv array, never + through a shell.** The arguments are all validated, but as soon as they pass + through `/bin/sh` the defense rests on a single question: "was the validation + complete?" +- **Drain the pipe before `waitpid`.** The other way round is the classic pipe + deadlock: the child fills the 64 KiB buffer and blocks in `write` while we + block in `waitpid`. `process_runner.cc` has a matching test. +- **Interface names are only accepted in the form `feth`.** This is not + fastidiousness -- it blocks accidents of the "someone passes en0 and wipes + out the user's Wi-Fi configuration" kind. +- **The `SCDynamicStore` handle must be process-lifetime.** The values in the + dynamic store are held by the session that set them; once the session is + released the values are gone. A temporary store produces the extremely + hard-to-diagnose phenomenon "the write returns success, the read comes back + empty". + +### 4.4 Authorization re-check (`TetherKitIPC/Authorization.swift`) + +Three details that turn into a vulnerability the moment they are written wrong; +all of them are documented in the comments of that file: + +1. On the helper side, the re-check must **never** carry + `.interactionAllowed` -- a daemon has no UI session, and letting it pop a + dialog would let any process trigger the system authorization dialog at + will to harass the user. +2. **`AuthorizationFree` must not carry `.destroyRights`** -- the credential + belongs to the App, the helper only borrows it to check, and carrying it + would void the App's authorization as well. +3. **The probe methods do not require authorization** -- otherwise the two + failures "the helper is not installed" and "the authorization did not pass" + get mixed together and the user cannot be given an accurate message. + +Obtaining the credential (App side, **with** `.interactionAllowed`) and +re-checking it (helper side, **without**) are deliberately kept in the same +file: they differ by a single flag, so when one side is changed the other is +right in front of you. + +A fourth one, **actually hit in practice**: + +4. **The 32 bytes produced by `AuthorizationMakeExternalForm` are not the + credential itself; they are only a key pointing at the authorization inside + securityd.** If the App side releases the `AuthorizationRef` **before** the + XPC round trip **completes** (especially with `.destroyRights`), the helper + fails to restore it and reports `errAuthorizationDenied (-60005)`. + + And from the code alone this error code gives no hint of "it was destroyed + early by ourselves" -- it looks like the user denied the authorization. So + the credential is held by `AuthorizationToken`, and + `AuthorizationBroker.withAuthorization` uses `withExtendedLifetime` to + guarantee that it lives until the call ends. **Do not bare-catch the return + value of `requestAuthorization()` and use it afterwards** -- ARC is free to + release the token right after the last read of `externalForm`. + + `gui/Tests/TetherKitIPCTests/AuthorizationTests.swift` nails this behaviour + down; neither of the two cases pops an authorization dialog, so it can go + into CI. + +**The token must be cached and reused, otherwise every operation makes the user +authenticate again.** The measured parameters of `system.privilege.admin` +(`security authorizationdb read`): + +| Field | Value | Meaning | |---|---|---| -| `shared` | `false` | 凭据**不跨 AuthorizationRef 共享**。每次 `AuthorizationCreate` 建新 ref 就必然重新认证 | -| `timeout` | `300` | 但同一个 ref 上的凭据 5 分钟内有效 | - -所以 App 缓存 `AuthorizationToken` 复用:第一次操作弹一次框,之后 5 分钟内 -「配网络」「断开」都不再打扰用户。过期后 helper 的复核会失败并把应答的第二个 -参数置为 `true`,App 据此丢弃缓存、重新弹一次框、把这次操作重试一遍。 - -### 4.5 关于 Touch ID:这条路走不通(已验证) - -**结论:`AuthorizationCopyRights` 弹出的系统授权框不支持指纹,只能输密码。** -这是 API 层面的限制,不是实现问题。别再花时间试了。 - -验证过程(本机 Touch ID 硬件存在、`bioutil -r` 显示已录入并启用): - -1. 写了个最小的探针 App 请求 `system.privilege.admin`,读取 SecurityAgent - 窗口的文本,得到的是 **「输入密码允许此操作。」** —— 界面上没有任何生物识别 - 入口。 -2. 对比系统自己的 `system.preferences` 规则,`class` / `group` / - `authenticate-user` 完全相同,只有 `shared` 与 `timeout` 不同 —— - 而那两个字段只影响凭据缓存,不影响认证方式。所以换个权利名没有用。 -3. 在 SDK 里找 LocalAuthentication 与 Authorization Services 之间的桥: - `AuthorizationTags.h` 的环境项只有 username / password / shared / prompt / - icon,**没有 LAContext**。唯一出现 `GetLAContext` 的地方是 - `AuthorizationPlugin.h` —— 那是给**编写 SecurityAgent 插件**用的回调, - 而且注释写明它返回的是智能卡 PIN(`LACredentialCTKPIN`),不是 Touch ID。 - -`LAContext.evaluatePolicy` 确实能弹指纹,但它产出的结果**无法转换成 helper -可以复核的凭据** —— 换成它就等于放弃了整个信任模型。真要做,只剩「自己写一个 -SecurityAgent 插件装进 /Library/Security/SecurityAgentPlugins」这条路, -代价与风险都和这个项目完全不成比例。 - -可行的替代是**减少弹框频率**,也就是上面的令牌缓存。若还嫌频繁,可以在 -`install-helper.sh` 里用 `security authorizationdb write` 装一条自己的权利, -把 `timeout` 调长、`shared` 设为 true —— 但那仍然是密码框,只是问得更少。 - -### 4.6 XPC 接口修订号 - -`HelperConstants.protocolRevision` 每次改动 `TetherKitHelperProtocol` 都要加一。 -当前是 **3**(1 初版;2 特权方法应答加上「是否授权失败」;3 新增 `setLanguage`)。 -helper 把它编进 `helperVersion` 的应答,App 一连上就比对。 - -**为什么不新增一个专门的方法来报版本**:新增方法本身就是一次协议变更,旧 -helper 根本没有它 —— 那就又回到了「对不上还查不出来」。只有复用旧 helper 也 -一定会应答的方法,才能可靠识别出旧 helper。旧 helper 返回的串里没有分隔符, -解析出的修订号是 0,界面据此显示「特权组件需要更新」。 - -### 4.7 Swift 侧 - -- **XPC 的错误块与 reply 块可能都被调用**(请求已发出后连接才断),而 - `CheckedContinuation` 兑现两次是直接崩溃。`HelperClient` 里每次调用都用 - `ContinuationGuard` 包一层。 -- **连接断掉后必须丢弃缓存的 `NSXPCConnection`**,否则后续调用一直打在死连接 - 上,表现为「helper 明明装好了却连不上」。 -- **helper 里凡是可能耗时的操作都异步回复**(启动会话要 USB 握手,DHCP 要等 - 租约最多 10 秒)。在 XPC 队列上阻塞会把同一条连接上后续的状态轮询一起卡住, - 界面表现成整个卡死。 -- **速率的分母用库那边的单调时钟差**,不是界面定时器周期 —— 两次拉取之间的真实 - 间隔会被调度拉长,用固定周期当分母会把速率算高。 -- **C 的定长 char 数组在 Swift 里是元组**,不能下标也不能直接转 String,统一走 - `CInterop.swift`。读用「扫到 NUL 或扫到底」,不用 `String(cString:)` —— 后者 - 在缓冲被写满、没有 NUL 时会越界读。 -- **非负值的 C 枚举被导入成 `UInt32`**,而结构体字段是 `int32_t`,比较前必须 - 显式转换。 -- **主窗口由 AppKit 直接管理(`MainWindowManager`),不再走 SwiftUI 的 Window - 场景。** 两个原因:一是 SwiftUI 会在菜单栏模式下「擅自重建」Window 场景、 - 主窗口莫名冒回来(`applicationShouldHandleReopen` 不走,插桩确认没被调用, - 没有干净的委托口子可堵)—— 自己管窗口之后这条路就没了;二是 macOS 26 的 - 窗口 chrome 由 `LC_BUILD_VERSION` 的 SDK 决定:SwiftPM 链出来的 `sdk 14.0` - 全机器都是 legacy 三灯(14×16pt 按钮框、20pt 间距、内只画约 12pt 圆),加 - `-platform_version macos 14.0.0 26.5.0` 并保持 minos 14.0 即可拿回 stock - 大灯(两个方面均见 AGENTS.md 第 7 节第 19–21 条)。主窗口因此是 - `fullSizeContentView` + 透明隐藏标题栏的普通 NSWindow,内容由 - NSHostingView 承载;App 启动与菜单栏「打开主窗口」都走 - `MainWindowManager.shared.present(model)`,关闭 = orderOut,回到菜单栏 - 模式(`NSWindowDelegate.windowWillClose` 里退到 accessory 策略)。 -- **dev 裸跑现在照常弹窗。** 窗口由 delegate 在 - `applicationDidFinishLaunching` 创建并 `makeKeyAndOrderFront`,不再依赖 - SwiftUI 的实例化时机,也不需要菜单栏手动救回。 - `TetherKitApplication.init()` 的 `.prohibited → .regular` 自检保留: - 它管的是未激活进程的正常激活语义。 -- **轮询是自适应的**:会话在跑 / 正在启停 / 窗口开着 → 500 ms;纯后台待机 → - 2 秒。窗口重新打开时立刻补一次刷新,避免第一眼看到陈旧数据。 - -### 4.7b 界面语言 - -文案表编译进二进制,不走 `.lproj`。理由写在 `TetherKitIPC/Localization.swift` -顶部,核心是 **helper 是装在 `/Library/PrivilegedHelperTools` 的裸可执行文件**, -旁边没有、也不该有资源 bundle,而它同样要产生给用户看的文字。 - -改语言时必须**同时**更新三处,缺一处就会出现「界面英文、日志中文」: - -| 处 | 怎么改 | 不改的后果 | +| `shared` | `false` | The credential is **not shared across AuthorizationRefs**. Every new ref created by `AuthorizationCreate` necessarily re-authenticates. | +| `timeout` | `300` | But a credential on the same ref stays valid for 5 minutes. | + +So the App caches the `AuthorizationToken` and reuses it: the first operation +pops one dialog, and for the next 5 minutes neither "configure the network" nor +"disconnect" bothers the user any more. Once it expires, the helper's re-check +fails and sets the second parameter of the reply to `true`; the App then drops +the cache, pops the dialog once more and retries the operation. + +### 4.5 About Touch ID: this road does not work (verified) + +**Conclusion: the system authorization dialog popped by +`AuthorizationCopyRights` supports no fingerprint, only a password.** This is a +limitation of the API, not an implementation problem. Do not spend more time +trying. + +The verification process (this machine has Touch ID hardware, `bioutil -r` shows +it enrolled and enabled): + +1. Wrote a minimal probe app that requests `system.privilege.admin`, read the + text of the SecurityAgent window, and got **"Enter password to allow this + operation."** -- there is no biometric entry point anywhere in the interface. +2. Compared it with the system's own `system.preferences` rule: `class` / + `group` / `authenticate-user` are exactly the same, only `shared` and + `timeout` differ -- and those two fields only affect the credential cache, + not the authentication method. So a different right name does not help. +3. Looked in the SDK for a bridge between LocalAuthentication and the + Authorization Services: the environment items in `AuthorizationTags.h` are + only username / password / shared / prompt / icon, **no LAContext**. The + only place `GetLAContext` appears is `AuthorizationPlugin.h` -- that is a + callback for **writing a SecurityAgent plugin**, and its comment says it + returns a smart card PIN (`LACredentialCTKPIN`), not a Touch ID. + +`LAContext.evaluatePolicy` really can pop a fingerprint prompt, but the result +it produces **cannot be converted into a credential the helper can +re-check** -- switching to it would mean giving up the entire trust model. If +you really want to do it, the only road left is "write your own SecurityAgent +plugin and install it into /Library/Security/SecurityAgentPlugins", and both +the cost and the risk are wildly out of proportion for this project. + +The workable alternative is to **reduce how often the dialog pops**, which is the +token caching described above. If that still feels too frequent, you can use +`security authorizationdb write` in `install-helper.sh` to install a right of +your own, lengthen `timeout` and set `shared` to true -- but that is still a +password box, it just asks less often. + +### 4.6 XPC interface revision number + +`HelperConstants.protocolRevision` must be incremented every time +`TetherKitHelperProtocol` changes. It is currently **3** (1: the first +version; 2: the reply of a privileged method carries "did the authorization +fail"; 3: adds `setLanguage`). The helper encodes it in the `helperVersion` +reply, and the App compares it as soon as it connects. + +**Why not add a dedicated method just to report the version**: adding a method +is itself a protocol change, and the old helper simply does not have it -- that +puts us back to "mismatched and undetectable". Only a method that even an old +helper is guaranteed to answer can reliably identify an old helper. The string +returned by an old helper contains no separator, the parsed revision number +comes out as 0, and the interface uses that to show "the privileged helper +needs updating". + +### 4.7 The Swift side + +- **The XPC error block and the reply block may both be invoked** (the + connection dropped after the request went out), and fulfilling a + `CheckedContinuation` twice is a straight crash. Every call in + `HelperClient` is wrapped in a `ContinuationGuard`. +- **The cached `NSXPCConnection` must be discarded after the connection + drops**, otherwise every later call keeps hitting the dead connection, which + shows up as "the helper is clearly installed but cannot be reached". +- **Every operation in the helper that might take time replies asynchronously** + (starting a session needs a USB handshake, DHCP waits for a lease for up to + 10 seconds). Blocking on the XPC queue stalls the later status polls on the + same connection, and the interface looks completely frozen. +- **The denominator of the rate is the monotonic clock delta on the library + side**, not the interface timer period -- the real interval between two + fetches is stretched by the scheduling, and using a fixed period as the + denominator computes the rate too high. +- **A fixed-length C char array is a tuple in Swift**; it cannot be + subscripted and cannot be turned into a String directly, so everything goes + through `CInterop.swift`. Read it by "scan to the NUL or to the end", not + with `String(cString:)` -- the latter reads out of bounds when the buffer is + full and has no NUL. +- **A non-negative C enum is imported as `UInt32`** while the struct field is + `int32_t`, so an explicit conversion is required before comparing. +- **The main window is managed directly by AppKit (`MainWindowManager`), no + longer through a SwiftUI Window scene.** Two reasons: first, SwiftUI will + "recreate the Window scene on its own" in menu bar only mode and the main + window mysteriously comes back (`applicationShouldHandleReopen` is not taken; + instrumentation confirmed that it is never called, and there is no clean + delegate hook to block it) -- managing the window yourself removes that road + entirely; second, on macOS 26 the window chrome is decided by the SDK in + `LC_BUILD_VERSION`: a SwiftPM-linked `sdk 14.0` gives the whole machine the + legacy traffic lights (14x16pt button frames, 20pt spacing, only a roughly + 12pt circle drawn inside), and adding `-platform_version macos 14.0.0 26.5.0` + while keeping minos 14.0 gets the stock traffic lights back (both aspects are + in items 19-21 of section 7 of AGENTS.md). The main window is therefore an + ordinary NSWindow with `fullSizeContentView` plus a transparent, hidden title + bar, its content hosted by an NSHostingView; both the App startup and the + menu bar's "Open the main window" go through + `MainWindowManager.shared.present(model)`, closing = orderOut, back to menu + bar only mode + (`NSWindowDelegate.windowWillClose` falls back to the accessory policy). +- **A bare dev run now pops the window as usual.** The window is created by + the delegate in `applicationDidFinishLaunching` and `makeKeyAndOrderFront`-ed, + no longer depending on the instantiation timing of SwiftUI and no longer + needing a manual rescue from the menu bar. The `.prohibited -> .regular` + self-check in `TetherKitApplication.init()` is kept: it governs the normal + activation semantics of a process that is not active yet. +- **The polling is adaptive**: a running session / starting or stopping / the + window open -> 500 ms; purely idle in the background -> 2 seconds. When the + window is reopened, a refresh is issued immediately so that the first glance + is not stale data. + +### 4.7b Interface language + +The string table is compiled into the binary, it does not go through `.lproj`. +The reason is written at the top of `TetherKitIPC/Localization.swift`, and its +core is that **the helper is a bare executable installed in +`/Library/PrivilegedHelperTools`**, with no resource bundle next to it, and +there should not be one, yet it still has to produce user-facing text of its +own. + +The table currently holds a single column (English) and the GUI language menu +offers "Follow system" and "English"; the multi-language plumbing is +deliberately kept so that adding a language later stays a bounded five-touch +change (the checklist lives at the top of +`include/tetherkit/common/messages.def`). + +When the language changes, **all three** places must be updated together, and +missing one of them produces "the interface in one language, the log in +another": + +| Place | How to change it | Consequence of not changing it | |---|---|---| -| Swift 文案表 | `L10n.apply(_:)` | 界面不变 | -| libtetherkit | `TetherKitLibrary.setLanguage(_:)` → `tk_set_language` | 日志卡里的库日志还是旧语言 | -| helper | XPC 的 `setLanguage(_:)` | helper 的提示与它那边的库日志还是旧语言 | - -三处都在 `AppModel.applyLanguage` 里一起做,别在别处单独调其中一个。helper -之所以要单独告知:它以 root 跑在 launchd 下,看不到用户的语言偏好。 - -两条 SwiftUI 特有的坑(都实测踩过): - -- **切语言不会让任何 `@Observable` 属性看起来变过**,视图体因此不会重算。 - 对策是把语言变化本身变成可观察的值(`AppModel.languageRevision` 每次加一), - 再当 identity 用:`.id(model.languageRevision)`,强制重建整棵树。 -- **`.commands { }` 里的每个 `Button` 各自是独立表达式**,只有读过可观察值的 - 那个会重算。实测切完语言只有语言菜单自己变了,旁边的「检查更新…」还是旧 - 语言。修法是把这一组包进同一个 View(`AppMenuItems`),在它的 body 里读一次 - revision。同理,**存文案的 `static let` 只求值一次**,一律改成计算属性。 - -### 4.8 打包 - -- **拷 dylib 必须用 `cp -a`,不能用 `install`。** `libtetherkit.dylib` 与 - `libtetherkit.0.dylib` 是软链,`install` 会各拷一份独立的真实文件,之后 - `install_name_tool` 只改到其中一份,而按 `@rpath` 加载的恰好是没改到的那份。 -- **`install_name_tool` 改完字节必须重签。** arm64 要求有效签名,改字节会让原 - 签名失效,之后加载被内核直接拒绝(`Killed: 9`),日志里看不出原因。 -- **libusb 是 `libtetherkit.dylib` 的依赖,不是可执行文件的。** 对着可执行文件 - 查 `otool -L` 会得到空结果,整段处理被静默跳过 —— 装完看起来正常,实际仍依赖 - Homebrew。 -- **发布产物里必须删掉指向构建目录的 rpath。** 它是绝对路径,在开发机上一定 - 存在,dyld 会优先用它,内嵌的副本永远得不到验证 —— 等换台机器才暴露。 - `build-gui.sh` 会删;`Package.swift` 里也把它排在最后作为第二道保险。 -- **bash 里变量后面紧跟中文标点必须写 `${VAR}`。** UTF-8 locale 下全角标点的 - 首字节(0xEF)会被 `isalnum()` 判为真、吸进变量名。C locale 下不复现。 - -### 4.9 App 内一键安装特权组件(AEWP) - -helper 缺失或版本不匹配时,界面给一个「安装 / 更新特权组件」按钮,点一下、 -输一次管理员密码就装好 —— 不用打开终端。机制(`HelperInstaller.swift`): - -- **载荷内嵌在 .app 里**:`Contents/Library/HelperTools/` 平铺放着 helper - 二进制、dylib、plist 和安装 / 卸载脚本,`build-gui.sh` 组装。装的时候整目录 - 拷走,安装源天然跟着 .app 走。旧的 `dist/helper/` 布局已废除。 -- **提权走 `AuthorizationExecuteWithPrivileges`(AEWP)**:它接的正是 - AuthorizationRef,所以 App 缓存的授权令牌(4.4 节)直接复用 —— 5 分钟窗口内 - 一次框都不弹;装完接着点「连接」也不再弹。osascript 方案被排除就是因为它 - 自建授权会话,接不上这份缓存。SMJobBless / SMAppService 早已排除(SPIKE 3.3)。 -- **AEWP 已废弃但可用(macOS 26 实测)**:符号可解析,setuid 执行载体 - `/usr/libexec/security_authtrampoline` 仍在。代码用 dlsym 运行时解析而不是 - 直接链接:将来系统移除它时降级成「请用终端安装」,而不是 dyld 绑定失败。 -- **AEWP 的执行对象必须是二进制,不能是脚本**:AEWP 给子进程的凭据是 - euid=0 / ruid=普通用户,而 bash 在 euid ≠ ruid 且未带 `-p` 时会把 euid 降回 - ruid —— 直接 AEWP 安装脚本,脚本开头的 root 检查必然报「需要 root 权限」 - (**真机踩过**)。所以 App 拉起的是载荷里 helper 二进制的 `--install` 模式 - (`InstallerMode.swift`):setuid(0) 把凭据归一化成与 sudo 完全相同的真 - root,dup2 把 stderr 并进 stdout(AEWP 的管道只接 stdout),再 exec 同目录 - 的 `install-helper.sh`。脚本按「脚本所在目录 → 仓库产物」自动定位载荷, - 终端与 App 两个入口共用一份实现。 -- **成败不看退出码**:AEWP 不给 pid 也不给退出码,只有一根连到子进程 stdout - 的管道。判定标准是「读到 EOF 后能否真的连上匹配版本的 helper」—— - `AppModel.installHelper()` 用 XPC 往返兜底确认,失败时把脚本输出的末尾 - (剥掉 ANSI 色码)放进弹窗。 -- **弹框之前先自检**:载荷不在(`swift run` 的裸可执行文件)或 AEWP 不在, - 都要在要密码**之前**报出来,并引导回终端脚本。 -- **卸载走同一条链**:「管理」页的「卸载特权组件…」(低调的 caption 行 —— - 极低频动作不抢注意力,但必须存在于主界面)→ 确认框 → `--uninstall` → - `uninstall-helper.sh`。成败判定方向相反:等的是「连不上」。卸载后界面 - 自然回到安装引导页,随时可重装。 - -### 4.10 检查更新(只查不换) - -`UpdateChecker.swift` 请求 GitHub 的 `releases/latest`,和 Info.plist 里的 -版本号(CMakeLists 单一来源)做语义化比较。**刻意不做自动下载替换**: -免证书分发下,下载物带 quarantine,替换完的 .app 会被 Gatekeeper 拦死 —— -和不能走 Cask 是同一堵墙。真正的更新通道是 `brew upgrade` 或源码重编。 - -- 入口两个:App 菜单「检查更新…」(结果弹窗,带可复制的 brew 命令与 - 发布页链接);自动检查每天至多一次,发现新版在「管理」页的特权组件行点亮 - 「有新版 x.y.z」,绝不弹窗打断。提示跨启动记忆(defaults),升级完成后 - 因版本比较自然熄灭。 -- 隐私:只访问 GitHub 公开 API,失败静默; - `defaults write com.tetherkit.app updateCheckDisabled -bool YES` 彻底关闭。 -- `swift run` 的裸可执行文件没有 Info.plist 版本号,检查自动跳过。 -- 发版纪律:检查读的是 GitHub Releases,**每个版本必须打 `vX.Y.Z` tag 并 - 发布 Release**,否则查不到。 -- 查到新版后的落地由既有机制接力:`brew upgrade` 换掉 App,重启后 - `protocolRevision` 不匹配触发「更新特权组件」一键重装(4.6 / 4.9 节)。 - -### 4.11 页面切换为何零动画(macOS 26 的插值链) - -侧栏换页要求逐像素不动。此前 macOS 26 上点击换页时新页短暂放大约 8-10% 再缩回, -且**只有点击触发**(定时器驱动同一状态完全纯净)。两层根因(AGENTS.md 第 7 节 -第 22、23 条)叠加,单堵任何一层都只让跳变变快、不能根除 —— 三重钳制,缺一不可: - -1. **`SidebarRow`:`InstantButtonStyle`(`PrimitiveButtonStyle`)** —— 系统默认 - 样式会为按下 / 标签状态创建**按钮激活上下文**(带动画的事务);它用 +| Swift string table | `L10n.apply(_:)` | the interface does not change | +| libtetherkit | `TetherKitLibrary.setLanguage(_:)` -> `tk_set_language` | the library logs in the log card stay in the old language | +| helper | the XPC `setLanguage(_:)` | the helper's messages and the library logs on its side stay in the old language | + +All three are done together in `AppModel.applyLanguage`; do not call one of +them on its own somewhere else. The helper has to be told separately because it +runs as root under launchd and cannot see the user's language preference. + +Two SwiftUI specific pitfalls (both hit in practice): + +- **Switching the language does not make any `@Observable` property look + changed**, so the view bodies do not recompute. The countermeasure is to + make the language change itself an observable value + (`AppModel.languageRevision` increments every time), and then use it as an + identity: `.id(model.languageRevision)`, which forces the whole tree to be + rebuilt. +- **Each `Button` inside `.commands { }` is its own expression**, so only the + one that reads an observable value recomputes. In practice, after switching + the language only the language menu itself changed, while "Check for + Updates…" next to it was still in the old language. The fix is to wrap that + group in a single View (`AppMenuItems`) and read the revision once in its + body. The same goes for the **`static let` members that hold text**: they + are evaluated only once, so turn them all into computed properties. + +### 4.8 Packaging + +- **Copying the dylib must use `cp -a`, never `install`.** `libtetherkit.dylib` + and `libtetherkit.0.dylib` are symlinks; `install` copies a separate real + file for each, and afterwards `install_name_tool` only changes one of them + -- while the one that is loaded via `@rpath` is exactly the one that was not + changed. +- **After `install_name_tool` has changed the bytes, the signature must be + redone.** arm64 requires a valid signature; changing the bytes invalidates + the original signature and the load is then rejected by the kernel outright + (`Killed: 9`), with no reason visible in the log. +- **libusb is a dependency of `libtetherkit.dylib`, not of the executable.** + Running `otool -L` against the executable yields an empty result, the whole + step is silently skipped -- after the install everything looks normal, yet + it still depends on Homebrew. +- **The rpath that points at the build directory must be removed from the + release artifacts.** It is an absolute path, it certainly exists on the + development machine, dyld prefers it, and the embedded copy is therefore + never validated -- it only shows up on a different machine. `build-gui.sh` + removes it; `Package.swift` also puts it last as a second line of defence. +- **In bash, a variable immediately followed by Chinese punctuation must be + written as `${VAR}`.** Under a UTF-8 locale the lead byte of the full-width + punctuation (0xEF) is judged true by `isalnum()` and absorbed into the + variable name. It does not reproduce under the C locale. + +### 4.9 One-click installation of the privileged helper inside the App (AEWP) + +When the helper is missing or its version does not match, the interface offers +an "Install the helper" or "Update the helper" button: click it, type the +administrator password once, and it is installed -- no need to open a terminal. +The mechanism (`HelperInstaller.swift`): + +- **The payload is embedded in the .app**: `Contents/Library/HelperTools/` + holds the helper binary, the dylib, the plist and the install / uninstall + scripts flat, assembled by `build-gui.sh`. At install time the whole + directory is copied away, so the install source naturally travels with the + .app. The old `dist/helper/` layout is abolished. +- **The privilege escalation goes through `AuthorizationExecuteWithPrivileges` + (AEWP)**: it takes exactly the AuthorizationRef, so the authorization token + cached by the App (section 4.4) is reused directly -- within the 5-minute + window not a single dialog pops; clicking "Connect" right after the install + does not pop one either. The osascript route was ruled out precisely because + it creates its own authorization session and cannot hook into this cache. + SMJobBless / SMAppService were ruled out long ago (SPIKE 3.3). +- **AEWP is deprecated but usable (measured on macOS 26)**: the symbol still + resolves, and the setuid trampoline `/usr/libexec/security_authtrampoline` + is still there. The code resolves it with dlsym at runtime instead of linking + it directly: if the system ever removes it, the app degrades into "please + install from a terminal" instead of failing to bind at dyld time. +- **The execution target of AEWP must be a binary, not a script**: the + credential AEWP gives the child process is euid=0 / ruid=the normal user, + and bash (zsh and dash likewise) drops the euid back to the ruid when + euid != ruid and `-p` is not given -- the old rule against setuid scripts. + AEWP-ing the install script directly yields, on a real machine, the script's + own "root privileges required" error (**hit in practice**). So what the App + launches is the `--install` mode of the helper binary in the payload + (`InstallerMode.swift`): setuid(0) normalizes the credential into exactly + the same real root as sudo, dup2 merges stderr into stdout (AEWP's pipe only + picks up stdout), and then it execs the `install-helper.sh` in the same + directory. The script locates the payload by "the directory the script is in + -> the repository artifacts", so the terminal and the App share one + implementation. +- **Success is not judged by the exit code**: AEWP gives neither a pid nor an + exit code, only a pipe connected to the child's stdout. The criterion is + "after reading EOF, can we really connect to a helper of the matching + version" -- `AppModel.installHelper()` confirms it with an XPC round trip as + a backstop, and on failure it puts the tail of the script output (with the + ANSI colour codes stripped) into the dialog. +- **Self-check before the dialog**: if the payload is missing (the bare + executable of `swift run`) or AEWP is gone, that has to be reported **before** + the password is requested, and the user guided back to the terminal script. +- **The uninstall goes through the same chain**: the "Uninstall the privileged + helper?" (a low-key caption line on the "Manage" page -- a very rare + action must not steal the attention, but it must exist in the main + interface) -> confirmation dialog -> `--uninstall` -> `uninstall-helper.sh`. + The success criterion runs the other way: what is waited for is "cannot + connect". After the uninstall the interface naturally returns to the install + guide page, and it can be reinstalled at any time. + +### 4.10 Update check (check only, never replace) + +`UpdateChecker.swift` requests GitHub's `releases/latest` and does a semantic +comparison against the version number in Info.plist (CMakeLists is the single +source of truth). **The automatic download and replace is deliberately not +done**: under certificate-free distribution the download carries quarantine, +and the replaced .app gets blocked by Gatekeeper -- the same wall that rules +out a Cask. The real update channel is `brew upgrade` or rebuilding from the +source. + +- Two entry points: the App menu's "Check for Updates…" (a result dialog + with a copyable brew command and a link to the release page); the automatic + check runs at most once a day, and when a new version is found the + privileged helper row on the "Manage" page lights up "x.y.z is available" -- + it never pops a dialog to interrupt. The hint is remembered across the + launches (defaults) and goes out by itself after the upgrade because of the + version comparison. +- Privacy: only the public GitHub API is accessed, and the failures are + silent; `defaults write com.tetherkit.app updateCheckDisabled -bool YES` + turns it off completely. +- The bare executable of `swift run` has no Info.plist version number, so the + check skips itself automatically. +- Release discipline: the check reads the GitHub Releases, so **every version + must be tagged `vX.Y.Z` and published as a Release**, otherwise it is not + found. +- When a new version is found, the existing mechanisms take over: + `brew upgrade` replaces the App, and after the restart the + `protocolRevision` mismatch triggers the one-click "update the privileged + helper" reinstall (sections 4.6 / 4.9). + +### 4.11 Why the page switch has zero animation (the interpolation chain of macOS 26) + +Switching pages in the sidebar has to be pixel-exact. Previously, on macOS 26, +clicking to switch pages briefly scaled the new page up by about 8-10% and then +back down, and **only a click triggered it** (the same state driven by a timer +was completely clean). Two layers of root cause (items 22 and 23 in section 7 of +AGENTS.md) stack up; blocking either one alone only makes the jump faster and +does not eliminate it -- three clamps, none of them optional: + +1. **`SidebarRow`: `InstantButtonStyle` (`PrimitiveButtonStyle`)** -- the + default system style creates a **button activation context** (a transaction + with an animation) for the pressed / label state; it uses `.transaction { animation = nil }` + `.contentShape(Rectangle)` + - `.simultaneousGesture(TapGesture().onEnded { trigger() })` 彻底不创建 - (对 configuration 调 `trigger()` 不是 `action()`)。 -2. **`SidebarShell.select`:状态写入跳出激活拍** —— `DispatchQueue.main.async` - 到下一拍写入,写处再挂 instant 事务(`animation = nil` + - `disablesAnimations = true`)兜底;content/pageBody 同挂 - `.transition(.identity)`,进出场永不插值。 -3. **`MainWindowManager`:掐断 resize 事件源** —— macOS 26 把「内容尺寸变化」 - 与「窗口 frame 动画」同步(WWDC25-256、release notes 61158194); - `NSHostingView` 默认 `sizingOptions` 会把每页固有尺寸推进窗口(页高不同 → - 每次换页一次 resize)。`hosting.sizingOptions = []` 在**附加 contentView - 之前**设置;min/max 钉成固定尺寸。 - -**为什么单独堵一层不够**:transition 只在事务带动画时插值,激活上下文永远带 -动画;`disablesAnimations` 只拦新的隐式 `.animation` 写入(objc.io 的 -Transaction / transitions 两篇)—— 唯一「完全不插值」的环境是带 nil 动画的 -下一拍事务,即第 2 条。 - -**不变式**:视图侧改侧栏选中的新代码路径**必须走 `select()`**(保持跳出激活拍), -不许在激活事务内直接写 `storedPage` —— 每多一个换页入口,这条都得跟着保持。 -唯一例外是窗口认领路径(`SidebarMainWindow` 的方向键与 Return 认领):窗口层 -拿不到 shell 的模型,经 `ButtonTurn.write` 直写存储键,且刻意不复锚 AX 焦点 -(不调 `syncAXFocus`)—— 同为跳拍写,与该不变式语义一致。 -窗口侧:delegate 的 `windowDidBecomeMain` / `windowDidResize` 幂等重新钉 -min/max —— 实测 macOS 26 窗口展示后不久会把 min/max **重置为未设**,伴随一次 -+32pt 的 chrome settle(调用栈 `_setFrameCommon:display:fromServer:`); -`styleMask` 从不含 `.resizable`(min/max 只防程序化改动),幂等写法同时防 -`windowDidResize` 自递归。 - -**无障碍按压(VoiceOver 的 AXPress)**:按压轮内 `NSApp.currentEvent == nil`, -且 macOS 26.5 会把该轮内排进 `DispatchQueue.main.async` / -`Task { @MainActor }` 的块**全部丢弃**(正文不跑),同步写则存活。所以 -`select()` 按 `NSApp.currentEvent` 的事件类型分流:鼠标/键盘轮内维持 -「跳出拍再写」的本节铁律,无障碍按压轮直接同步写。键盘轮内直写还有第二个 -坑:换页重渲染会把同一按键事件重新派发,一次按键双步进页。完整排查记录 -见 AGENTS.md 第 7 节坑 24。 - -**键盘(激活管线,round 6)**:real FKA 的 Space 与方向键被 -universalaccessd 在 HID 层拦截(合成的 NSEvent 到不了那层),Space -转成对焦点行的进程外 AXPress —— 落到行 -`.accessibilityAction(.default)`,并由 `ButtonTurn.writeDirect` -直写(进程外 AX 轮次里残留的 `NSApp.currentEvent` 会骗跳拍,被 -26.5 丢弃);Return 原样通过,在 `SidebarMainWindow` -的 `performKeyEquivalent` 拦截(条件:辅助功能面板 FKA 开 -(`SelfAccessibility.isFullKeyboardAccessOn`,真值勘误见坑 24 -round 7)、自进程系统级 AX 焦点解析出行标题、field editor 未激活), -未匹配时穿透至聚焦行自身的 `.onKeyPress(.return)` 兜底。 -`NSApp.isFullKeyboardAccessEnabled` 读的是旧偏好 -`AppleKeyboardUIMode`(键盘导航),不是辅助功能面板的 FKA 开关, -面板开关关掉时行上残留的 AX 焦点会让裸 Space/Return 静默跳页 -(round 7 真机回归)。方向键:侧栏在 `performKeyEquivalent` -先行认领裸方向键直切页面(条件 FKA-off、第一响应者非文本输入框; -`.function` 位不算和弦,禁令须是显式修饰键集合),认领后容器 -不再二次步进;容器保留方向键 `.onKeyPress` 作为冒泡兜底;FKA-on 时方向键归 HID 层。其余细节: -`ButtonTurn.write`,LogCard 的层级筛选与清空按钮走同一条路,点击 -驱动的换筛选/清空不再带激活事务的插值。 - -**焦点应答同步与自愈探索(round 8,自愈逻辑已在 round 11 superseded)**:行的键盘参与按辅助功能面板真值 -门控(round 11 定型:每行声明 -`.focusable(SelfAccessibility.isFullKeyboardAccessOn || isSelected)`,容器不再挂 focusable); -`.focused`/`.accessibilityFocused` 双引擎逐行绑定,键盘引擎落点 -经 `onChange(of: focusedRow)` 同步为 AX 应答 —— 这是进程内挪 AX -焦点的唯一受支持通道,外部写 `kAXFocusedUIElement` 是 no-op(坑 -24 round 8 ①)。(历史探索 / superseded):Round 8 曾尝试在轮询重渲染后通过 -`.onChange(of: model.status.monotonicNanos)` 与 involvement gate 进行自读系统应答自愈。 -该机制在 Round 11 原生架构中已完全停用(移除相关自愈代码,焦点由 SwiftUI 原生引擎管理)。 -FKA-off 契约收窄为**仅方向键**(r8 曾用 `.focusable(false)`,后在 r9/r11 重构)。 -层级筛选从 segmented 改 `.menu`:segmented 在键盘导航与真 FKA 下按键无响应,`.menu` 的 -弹出菜单是键盘可操作的正统替代;弹出菜单运行在 -`NSEventTrackingRunLoopMode`,无头环境无法合成开合/提交(硬件 -清单项),菜单存在性以 CGWindowList 差分取证(坑 24 round 8 -④);筛选绑定改走 `ButtonTurn.routed`(`ButtonTurn` 一并移入 -TetherKitUIModel,commit 语义有单测)。 - -**Tab 重入侧栏与 Return 的行级后背(round 9,真机复检)**:用户真机 -复检推翻 r8 的 FKA-off「仅方向键」契约 —— 键盘导航下 Tab 永远摸不到 -侧栏(行被 `.focusable(false)` 请出 Tab 环),且只要有控件持有焦点, -窗口对方向键的认领前置失效、方向键搁浅。契约改为:**Tab 在两种模式 -下都可达侧栏;Space/Return 在 FKA-off 里对行保持 inert;方向键从行 -焦点继续切页**。三处落地:行 `.focusable(true)` 常量化(条件修饰链 -再少一处,AX 身份更稳;r8 探针矩阵的期望项「Tab 永不落行」从此作 -废,未来矩阵先改期望);行 Button 动作统一走 `rowSelect`,FKA-off -时按 `NSApp.currentEvent` 的类型分流 —— 键盘驱动的原生激活(现场 -keyDown)丢弃、鼠标事件放行(`currentEvent` 的读取有两处 —— 此处与 -`ButtonTurn.write` 的跳拍判定 —— 都只作否定/分流信号用:AXPress 轮 -会残留旧事件,见坑 24 round 6);窗口方向键 -认领在第一响应者非文本输入框时步进页面。Return 补行级后背:真机 FKA 下窗口认领依赖的 AX 自读解析不出 -行(合成探针全过 —— 无头环境绕不开 HID 层,这一环只能真机验), -聚焦行自己挂 `.onKeyPress(.return)`(FKA-on 门,直调 `rowSelect`, -零 AX-read 依赖);小键盘 Enter(keycode 76)仍只有窗口认领覆盖 -(SwiftUI `KeyPress.Key` 无小键盘变体)。真机复检清单随之更新: -FKA-off —— Tab 触达侧栏行、Space/Return inert、行焦点下方向键切页; -FKA-on —— Tab 后 Return 也换页。 - -**自驱键盘环探索(round 10,已在 round 11 废弃 / superseded)**:round 9 的「Tab 两种模式 -可达」在早期探索中曾误判 FKA-off 下侧栏行不可达,round 10 尝试了由 AppKit 截获 -裸 Tab、在 `AppModel.sidebarRingRow` 上维护自驱键盘环的方案。但该方案存在 AppKit -截获容易侵占内容按钮焦序、且引入复杂状态机的问题。**该自驱键盘环方案已在 round 11 被完整移除 -并废弃**(`sidebarRingRow`、`SidebarFocusBridge` 均已删除),最终定型为 round 11 的纯原生架构。 - -**原生焦点环与全态方向键(round 11,最终定型架构)**: -1. **Tab 循环双向贯通**:r10 尝试的 AppKit 层自驱截获 Tab 会侵占内容按钮的自然焦序,而纯 SwiftUI 下之前侧栏被跳过的根因是 `rows` 容器上绑定的 `.focusable(SelfAccessibility.isFullKeyboardAccessOn)` 在 FKA-off 时将侧栏整组阻断。移除容器 focusable,选中行在 FKA-off 下使用 `.focusable(isSelected)`(FKA-on 下各行均可聚焦),Tab 形成「侧栏选中项 ↔ 内容各按钮」的双向可达循环,Tab 遍寻内容各按钮后平滑回至侧栏。 -2. **方向键全态切页**:原 `handleArrowKey` 守卫要求 `firstResponder == nil || firstResponder === contentView`,一旦用户按下 Tab 导致内容按钮(如 `SwiftUIAppKitButton` / `KeyViewProxy`)获焦,方向键即被误拦。改以 `!isFirstResponderEditableText` 为放行条件:只要第一响应者不是文本输入框(需保留光标移动),无论焦点在侧栏还是内容按钮,上下方向键均随时步进选区换页,换页后焦点重置自愈,用户绝不被卡在当前页。容器 `.onKeyPress` 保留上下方向键作为冒泡兜底。 -3. **Return 认领与行级后背**:FKA-on 下,窗口 `performKeyEquivalent` 在第一响应者不是 field editor 时,尝试读取系统级 FKA 焦点轨道(`SelfAccessibility.systemWideFocusedDescription()`)。⚠️ **TCC 权限说明**:自进程 UIElement 检查免权限,但通过 `AXUIElementCreateSystemWide()` 读取跨进程系统级焦点元素受 macOS TCC(辅助功能)门禁控制;应用默认不弹窗索权,未授予辅助功能权限时 `AXIsProcessTrusted()` 为假,系统级读取直接静默返回 nil,事件自然穿透至聚焦行自身的 `.onKeyPress(.return)` 兜底换页(无需任何系统权限)。 -4. **视觉反馈与窗口失焦**:行获焦且窗口处于活跃态(`controlActiveState == .key`)时展示 1pt accent 描边与浅灰底,窗口切至后台时描边自动隐去;选区跟随方向键同步移动。 - -**验证(无头,不靠肉眼)**:`NSEvent` mouseDown/mouseUp + -`NSWindow.sendEvent` 合成点击走真实响应链(无需 Accessibility / 录屏权限); -对自己进程可用 `AXUIElement` 读按钮几何自检命中(自家进程无 TCC 弹窗),命中 -12/12;每次点击抓 20 张间隔 35ms 的上部内容带帧 = 240 帧,YAVG 配对分析 —— -每次换页恰好**一个单帧跳变**、无衰减序列(若仍有 0.2-0.3s 的 settle,会出现 -5-8 个连续中幅配对),点击路径零 `didResize`。⚠️ CA 动画键探针全程 -`animLayers=0`:**SwiftUI 插值对 CALayer 不可见,只有位图捕获能看到**,别拿 -「有没有 CA 动画」当判据。AX 注意点:常量导入为 `String`(传给 AX API 要 -`as CFString`);`CFArray` 元素用 `CFArrayGetValueAtIndex` + `unsafeBitCast` -取,配 `AXValueGetTypeID()` 检查。 - -无碍项:`LogCard` 自动滚动(`withAnimation(.easeOut 0.15)`)与 `StatusHeroCard` -的 repeatForever 呼吸动画都拥有自己的事务,`select()` 的钳制不影响它们 -(实测通过)。 + `.simultaneousGesture(TapGesture().onEnded { trigger() })` to create none at + all (it calls `trigger()` on the configuration, not `action()`). +2. **`SidebarShell.select`: the state write jumps out of the activation + beat** -- `DispatchQueue.main.async` writes on the next beat, and the write + site carries an instant transaction (`animation = nil` + + `disablesAnimations = true`) as a backstop; content/pageBody also carry + `.transition(.identity)`, so the entry and the exit never interpolate. +3. **`MainWindowManager`: cut off the resize event source** -- macOS 26 + synchronizes the "content size change" with the "window frame animation" + (WWDC25-256, release notes 61158194); the default `sizingOptions` of + `NSHostingView` pushes each page's intrinsic size into the window (different + page heights -> one resize per page switch). `hosting.sizingOptions = []` is + set **before the contentView is attached**; min/max are pinned to a fixed + size. + +**Why blocking one layer alone is not enough**: a transition only interpolates +when the transaction carries an animation, and an activation context always +carries an animation; `disablesAnimations` only blocks new implicit `.animation` +writes (the two objc.io articles on Transaction / transitions) -- the only +environment that "does not interpolate at all" is the next beat transaction +with a nil animation, which is item 2 above. + +**The invariant**: every new code path on the view side that changes the +sidebar selection **must go through `select()`** (keeping the jump out of the +activation beat); writing `storedPage` directly inside an activation +transaction is forbidden -- every additional page switch entry point has to +keep this. The only exception is the window claim path (the arrow keys and the +Return claim of `SidebarMainWindow`): the window layer has no access to the +shell's model, so it writes the storage key directly through +`ButtonTurn.write`, and deliberately does not re-anchor the AX focus (it does +not call `syncAXFocus`) -- a jump out write as well, semantically consistent +with the invariant. On the window side: the delegate's `windowDidBecomeMain` / +`windowDidResize` idempotently re-pins min/max -- measured, macOS 26 resets +min/max to "not set" shortly after the window is shown, accompanied by a +32pt +chrome settle (call stack `_setFrameCommon:display:fromServer:`); the +`styleMask` never contains `.resizable` (min/max only guard against +programmatic changes), and the idempotent form also prevents `windowDidResize` +from recursing into itself. + +**The accessibility press (VoiceOver's AXPress)**: within the press round +`NSApp.currentEvent == nil`, and macOS 26.5 **discards everything** that was +queued into `DispatchQueue.main.async` / `Task { @MainActor }` within that +round (the body never runs), while a synchronous write survives. So `select()` +splits on the event type of `NSApp.currentEvent`: inside a mouse/keyboard round +it keeps the iron rule of this section, "jump out of the beat and then write", +and in an accessibility press round it writes synchronously. A direct write +inside a keyboard round has a second pitfall: the re-render after the page +switch re-dispatches the same key event, so one keypress advances two pages. +The complete investigation record is in pitfall 24 in section 7 of AGENTS.md. + +**The keyboard (the activation pipeline, round 6)**: with a real FKA the Space +and the arrow keys are intercepted by universalaccessd at the HID layer (a +synthesized NSEvent never reaches that layer), and the Space becomes an +out-of-process AXPress on the focused row -- it lands on the row's +`.accessibilityAction(.default)` and is written directly by +`ButtonTurn.writeDirect` (the `NSApp.currentEvent` left over from an +out-of-process AX round fools the beat detection and is discarded by 26.5); +the Return passes through unchanged and is intercepted in the +`performKeyEquivalent` of `SidebarMainWindow` (condition: the accessibility +panel's FKA is on (`SelfAccessibility.isFullKeyboardAccessOn`, the ground +truth correction is in pitfall 24 round 7), the system-wide AX focus of the +own process resolves to the row title, and no field editor is active), and +when nothing matches it falls through to the focused row's own +`.onKeyPress(.return)` backstop. `NSApp.isFullKeyboardAccessEnabled` reads the +old preference `AppleKeyboardUIMode` (keyboard navigation), not the +accessibility panel's FKA switch; with the panel switch off, the AX focus left +on a row makes a bare Space/Return silently switch pages (a real machine +regression in round 7). The arrow keys: the sidebar claims the bare arrow keys +in `performKeyEquivalent` first and switches the page directly (conditions: +FKA off, the first responder is not a text input; the `.function` bit is not a +chord, so the ban has to be an explicit modifier key set), after the claim the +container does not step a second time; the container keeps the arrow key +`.onKeyPress` as a bubbling backstop; with FKA on the arrow keys belong to the +HID layer. For the rest of the details: `ButtonTurn.write`; the level filter +and the clear button of the LogCard go through the same path, and a +click-driven filter change / clear no longer carries the interpolation of an +activation transaction. + +**The focus response synchronization and the self-healing exploration (round 8, +the self-healing logic was superseded in round 11)**: the keyboard +participation of the rows is gated on the ground truth of the accessibility +panel (the shape settled in round 11: every row declares +`.focusable(SelfAccessibility.isFullKeyboardAccessOn || isSelected)`, and the +container no longer carries focusable); the `.focused` / `.accessibilityFocused` +dual engines are bound row by row, and the landing point of the keyboard engine +is synchronized into the AX response via `onChange(of: focusedRow)` -- this is +the only supported channel for moving the AX focus inside the process, writing +`kAXFocusedUIElement` from the outside is a no-op (pitfall 24 round 8 (1)). +(Historical exploration / superseded): Round 8 once tried to self-heal the +system response after the polling re-render via +`.onChange(of: model.status.monotonicNanos)` and an involvement gate. That +mechanism was completely switched off in the Round 11 native architecture (the +related self-healing code was removed, the focus is now managed by the SwiftUI +native engine). The FKA-off contract was narrowed to **the arrow keys only** +(r8 used `.focusable(false)`, later restructured in r9/r11). The level filter +changed from segmented to `.menu`: segmented does not respond to the keyboard +under keyboard navigation and under a real FKA, and the `.menu` popup is the +orthodox keyboard-operable alternative; the popup runs in +`NSEventTrackingRunLoopMode`, and a headless environment cannot synthesize +opening and committing it (a hardware checklist item), so the existence of the +menu is proven by a CGWindowList diff (pitfall 24 round 8 (4)); the filter +binding now goes through `ButtonTurn.routed` (`ButtonTurn` was moved into +TetherKitUIModel as well, its commit semantics have unit tests). + +**Tab re-entering the sidebar and the row level fallback for the Return (round +9, real machine re-check)**: the user's real machine re-check overturned the +r8 FKA-off "arrow keys only" contract -- under keyboard navigation Tab could +never reach the sidebar (the rows were `.focusable(false)`, which excludes +them from the Tab ring), and as long as any control held the focus, the +window's arrow key claim preconditions failed and the arrow keys stalled. The +contract changed to: **Tab reaches the sidebar in both modes; Space/Return +stay inert on the rows with FKA off; the arrow keys keep switching pages from +the row focus**. Three landings: the rows' `.focusable(true)` became a +constant (one condition less, a steadier AX identity; the r8 probe matrix's +expectation "Tab never lands on a row" is void from now on, so change the +expectation before running the matrix again); the actions of the row Buttons +all go through `rowSelect`, and with FKA off they split on the type of +`NSApp.currentEvent` -- the keyboard driven native activation (the live +keyDown) is discarded, mouse events are let through (`currentEvent` is read in +two places -- here and in the beat detection of `ButtonTurn.write` -- and both +are used only as a negative / routing signal: an AXPress round leaves the old +event behind, see pitfall 24 round 6); the window's arrow key claim steps the +page when the first responder is not a text input. The Return gets the row +level fallback: under a real FKA the AX self-read that the window's claim +depends on cannot resolve the row (the synthesized probe passes everything -- +a headless environment cannot get around the HID layer, and only a real machine +can verify that link), so the focused row itself carries +`.onKeyPress(.return)` (an FKA-on gate, calling `rowSelect` directly, with +zero AX-read dependency); the keypad Enter (keycode 76) is still only covered +by the window claim (SwiftUI's `KeyPress.Key` has no keypad variant). The real +machine re-check list is updated accordingly: FKA off -- Tab reaches the +sidebar rows, Space/Return inert, the arrow keys switch the pages from the row +focus; FKA on -- the Return after a Tab also switches the page. + +**The self-driven keyboard ring exploration (round 10, removed and superseded +in round 11)**: round 9's "Tab reachable in both modes" had been misjudged in +the early exploration as "the sidebar rows are unreachable with FKA off", and +round 10 tried a scheme in which AppKit intercepts the bare Tab and a +self-driven keyboard ring is maintained on `AppModel.sidebarRingRow`. But that +scheme had the problems that the AppKit interception easily encroaches on the +focus order of the content buttons and that it introduces a complex state +machine. **That self-driven keyboard ring scheme was completely removed and +abandoned in round 11** (`sidebarRingRow` and `SidebarFocusBridge` are both +deleted), and the final shape is the purely native architecture of round 11. + +**The native focus ring and the arrow keys in every state (round 11, the final +architecture)**: + +1. **The Tab cycle is connected in both directions**: the AppKit level + self-driven interception of Tab tried in r10 encroaches on the natural focus + order of the content buttons, while the root cause of the sidebar being + skipped before, under pure SwiftUI, was the + `.focusable(SelfAccessibility.isFullKeyboardAccessOn)` bound to the `rows` + container, which blocks the whole sidebar group when FKA is off. Remove the + focusable of the container, the selected row uses `.focusable(isSelected)` + when FKA is off (with FKA on every row is focusable), Tab then forms a + bidirectional reachable cycle of "the sidebar selection <-> the content + buttons", and after Tab has walked through all the content buttons it + returns smoothly to the sidebar. +2. **The arrow keys switch the pages in every state**: the old + `handleArrowKey` guard demanded + `firstResponder == nil || firstResponder === contentView`, and as soon as + the user pressed Tab and a content button (such as `SwiftUIAppKitButton` / + `KeyViewProxy`) took the focus, the arrow keys were wrongly blocked. The + pass condition is now `!isFirstResponderEditableText`: as long as the first + responder is not a text input (where the caret has to keep moving), the up + and down arrow keys step the selection and switch the pages at any time, + whether the focus is in the sidebar or on a content button, and after the + switch the focus is reset and self-heals, so the user is never stuck on the + current page. The container keeps the up and down `.onKeyPress` as a + bubbling backstop. +3. **The Return claim and the row level fallback**: with FKA on, when the + first responder is not a field editor, the window's `performKeyEquivalent` + tries to read the system-wide FKA focus track + (`SelfAccessibility.systemWideFocusedDescription()`). Warning: **the TCC + permission note**: inspecting a UIElement of the own process needs no + permission, but reading the cross-process system-wide focus element through + `AXUIElementCreateSystemWide()` is gated by the macOS TCC (Accessibility); + the app does not prompt for it by default, and when the Accessibility + permission has not been granted `AXIsProcessTrusted()` is false, the + system-wide read silently returns nil, and the event naturally falls through + to the focused row's own `.onKeyPress(.return)` backstop which switches the + page (no system permission required). +4. **The visual feedback and the window losing the focus**: when a row has the + focus and the window is active (`controlActiveState == .key`), a 1pt accent + stroke and a light gray background are shown; when the window goes to the + background the stroke hides itself; the selection moves along with the arrow + keys. + +**The verification (headless, not by eye)**: a synthesized click of `NSEvent` +mouseDown/mouseUp + `NSWindow.sendEvent` goes through the real response chain +(no Accessibility / screen recording permission needed); `AXUIElement` can be +used against the own process to read the button geometry and self-check the +hit (no TCC prompt for one's own process), 12/12 hits; for each click, 20 +frames of the upper content band are captured at 35ms intervals = 240 frames, +and a YAVG pairing analysis shows that every page switch is exactly **a single +frame jump**, with no decaying sequence (if a 0.2-0.3s settle were still +there, 5-8 consecutive medium amplitude pairs would appear), and the click +path has zero `didResize`. Warning: the CA animation key probe stays at +`animLayers=0` throughout: **the SwiftUI interpolation is invisible to +CALayer, only a bitmap capture can see it** -- do not use "is there a CA +animation" as the criterion. AX notes: the constants are imported as `String` +(cast them to `as CFString` when passing them to the AX API); the `CFArray` +elements are taken with `CFArrayGetValueAtIndex` + `unsafeBitCast`, checked +against `AXValueGetTypeID()`. + +Not affected: the auto-scroll of the `LogCard` +(`withAnimation(.easeOut 0.15)`) and the repeatForever breathing animation of +the `StatusHeroCard` own their transactions, so the clamps of `select()` do not +affect them (verified in practice). --- -## 5. 已实现 / 未实现 +## 5. Implemented / not implemented -### 已实现 +### Implemented -| 能力 | 位置 | +| Capability | Where | |---|---| -| 环境预检(root、feth sysctl、MTU 上限) | `tk_check_environment` | -| 设备枚举 + 厂商名/产品名/序列号 | `tk_list_devices` | -| 会话生命周期(非阻塞启动、状态快照、事件) | `tk_session_*` | -| 日志捕获与轮询 | `tk_drain_logs` | -| DHCP / 静态 IP / 撤销 | `tk_net_apply`、`tk_net_clear` | -| 真实生效状态回读(地址、网关、DNS、主默认路由) | `tk_net_query` | -| 孤儿 feth 清理 | `tk_cleanup_orphan_interfaces` | -| 特权 helper + 凭据复核 | `gui/Sources/TetherKitHelper` | -| App 内一键安装 / 更新 / 卸载特权组件 | `gui/Sources/TetherKitApp/HelperInstaller.swift` | -| 检查更新(只查不换,每日自动 + 手动菜单) | `gui/Sources/TetherKitApp/UpdateChecker.swift` | -| Finder 别名自动维护(首次启动建立,聚焦可搜可启动;brew postinstall 有沙箱建不了) | `gui/Sources/TetherKitApp/FinderAlias.swift` | -| SwiftUI 界面(侧边栏五页:连接 / 网络 / 吞吐 / 日志 / 管理,`SidebarShell` 分页;日志页列表填满页高) | `gui/Sources/TetherKitApp/Views/SidebarShell.swift` | -| 菜单栏实时速率 + 后台运行(仅菜单栏模式) | `gui/Sources/TetherKitApp/Views/MenuBarPanel.swift` | -| dev 自截工具(App 窗口自捕成 PNG,无屏幕权限迭代循环) | `gui/Sources/TetherKitApp/Views/UiSelfCapture.swift` | -| 中英双语(运行期可切,界面 / 库日志 / helper 提示三处同步) | `gui/Sources/TetherKitIPC/Localization.swift`、`tk_set_language` | - -### 未实现 / 已知限制 - -| 项 | 说明 | +| Environment preflight (root, feth sysctls, MTU ceiling) | `tk_check_environment` | +| Device enumeration + vendor name / product name / serial number | `tk_list_devices` | +| Session lifecycle (non-blocking start, status snapshot, events) | `tk_session_*` | +| Log capture and polling | `tk_drain_logs` | +| DHCP / static IP / clearing | `tk_net_apply`, `tk_net_clear` | +| Read-back of the really effective state (address, gateway, DNS, main default route) | `tk_net_query` | +| Orphan feth cleanup | `tk_cleanup_orphan_interfaces` | +| Privileged helper + credential re-check | `gui/Sources/TetherKitHelper` | +| One-click install / update / uninstall of the privileged helper inside the App | `gui/Sources/TetherKitApp/HelperInstaller.swift` | +| Update check (check only, never replace; daily automatic + manual menu) | `gui/Sources/TetherKitApp/UpdateChecker.swift` | +| Automatic Finder alias maintenance (created on the first launch, searchable and launchable in Spotlight; the brew postinstall cannot create it because of the sandbox) | `gui/Sources/TetherKitApp/FinderAlias.swift` | +| SwiftUI interface (five sidebar pages: Connect / Network / Throughput / Log / Manage, paged by `SidebarShell`; the list of the log page fills the page height) | `gui/Sources/TetherKitApp/Views/SidebarShell.swift` | +| Live rate in the menu bar + background running (menu bar only mode) | `gui/Sources/TetherKitApp/Views/MenuBarPanel.swift` | +| Dev self capture tool (the App captures its own window into a PNG, so the iteration loop needs no screen recording permission) | `gui/Sources/TetherKitApp/Views/UiSelfCapture.swift` | +| English user-facing strings (the runtime language switch is kept, and the interface / the library log / the helper messages are switched together) | `gui/Sources/TetherKitIPC/Localization.swift`, `tk_set_language` | + +### Not implemented / known limitations + +| Item | Explanation | |---|---| -| **静态 IP 的 DNS 未经真机验证** | IPConfiguration 只在 DHCP 模式发布 DNS。静态模式我们往它建立的服务上补一个 DNS 键,能否被 IPMonitor 采纳未经验证。因此 `tk_net_query` 一律**回读**真实生效的解析器,界面显示回读结果 —— 赌错了也只是 DNS 不生效,不会有破坏 | -| **端到端已在真实设备上跑通(DHCP 路径)** | 2026-07-28 用 Android 手机验证:插上 → App 连接 → feth0 经 DHCP 拿到手机网段地址 → 主机与手机双向 ping 通 → iperf3 打出 RX 325 / TX 235 Mbps。**「能上公网」这一步没能验证** —— 本机有 VPN 占着默认路由,走 tether 的流量被劫走,这是测试环境问题不是 GUI 问题。App 退出后会话仍由 helper 持有(后台运行模式,符合设计) | -| **授权只能输密码,没有指纹** | 系统 API 的限制,不是缺陷。已验证并记录在第 4.5 节。缓解手段是令牌缓存:5 分钟内只问一次 | -| **热插拔** | `Context::SupportsHotplug()` 存在但未接入;目前靠界面每 2 秒重新枚举 | -| **IPv6** | 网卡配置只覆盖 IPv4 | -| **多会话** | helper 同一时刻只允许一个会话 | -| **App 图标** | 尚无 `.icns`,用系统默认图标 | -| **只有中文与英文** | 文案表是编译进二进制的两列(不是 `.lproj`,理由见 `Localization.swift` 顶部)。加第三种语言要改 `Language` 枚举与两处 switch,不是加一个目录就行 | +| **The DNS of the static IP is not verified on a real device** | IPConfiguration publishes DNS only in DHCP mode. In static mode we add a DNS key to the service it created, and whether IPMonitor adopts it has not been verified. Therefore `tk_net_query` always **reads back** the resolver that is really in effect, and the interface shows the read-back result -- if we guessed wrong, at worst the DNS does not take effect, nothing is broken. | +| **End to end already works on a real device (the DHCP path)** | Verified on 2026-07-28 with an Android phone: plug it in -> the App connects -> feth0 gets an address in the phone's subnet via DHCP -> the host and the phone can ping each other in both directions -> iperf3 measures RX 325 / TX 235 Mbps. **The step "can it reach the public internet" could not be verified** -- a VPN on this machine holds the default route and hijacks the tethered traffic, which is a problem of the test environment and not of the GUI. After the App quits the session is still held by the helper (background running mode, as designed). | +| **The authorization can only take a password, no fingerprint** | A limitation of the system API, not a defect. Verified and recorded in section 4.5. The mitigation is the token cache: it asks only once every 5 minutes. | +| **Hot plug** | `Context::SupportsHotplug()` exists but is not wired up; for now the interface re-enumerates every 2 seconds. | +| **IPv6** | The interface configuration only covers IPv4. | +| **Multiple sessions** | The helper allows only one session at a time. | +| **App icon** | There is no `.icns` yet, the default system icon is used. | +| **English only, for now** | The string table is compiled into the binary as a single column (not `.lproj`, the reason is at the top of `Localization.swift`). The multi-language plumbing is deliberately kept, so adding another language means changing the `Language` enum and two switch statements, it is not just a matter of adding a directory. | --- -## 6. 构建与安装 +## 6. Build and install ```bash -# 1. C++ 部分(产出 libtetherkit.dylib) +# 1. The C++ part (produces libtetherkit.dylib) cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo cmake --build build -j -# 2. GUI(等价于直接跑 ./gui/Scripts/build-gui.sh) +# 2. The GUI (equivalent to running ./gui/Scripts/build-gui.sh directly) cmake --build build --target gui -# 3. 运行。首次会引导安装特权组件:点按钮、输一次管理员密码即可(见 4.9 节)。 -# 终端替代:sudo ./gui/Scripts/install-helper.sh +# 3. Run. On the first launch it walks you through installing the privileged +# helper: click the button, type the administrator password once (see +# section 4.9). +# Terminal alternative: sudo ./gui/Scripts/install-helper.sh open dist/TetherKit.app ``` -开发时也可以直接用 SwiftPM,不经过打包脚本: +During development you can also use SwiftPM directly, without going through +the packaging script: ```bash export TETHERKIT_LIB_DIR="$PWD/build/lib" swift build --package-path gui -swift test --package-path gui # TetherKitIPC + TetherKitUIModel 的单元测试 +swift test --package-path gui # the unit tests of TetherKitIPC + TetherKitUIModel ``` -卸载:「管理」页的「卸载特权组件…」按钮(见 4.9 节),或: +Uninstall: the "Uninstall the privileged helper?" button on the "Manage" page +(see section 4.9), or: ```bash sudo ./gui/Scripts/uninstall-helper.sh ``` -排障入口: +Troubleshooting entry points: -| 现象 | 先看哪里 | +| Symptom | Where to look first | |---|---| -| 界面一直显示「需要先安装特权组件」 | `launchctl print system/com.tetherkit.helper` | -| helper 起不来 | `/var/log/tetherkit-helper.log` | -| 连接失败 | 界面里的「运行日志」面板(可一键复制) | -| 装完还是依赖 Homebrew | `otool -L dist/TetherKit.app/Contents/Frameworks/libtetherkit.0.*.dylib` | -| 点「连接」报「无法还原授权凭据(-60005)」 | App 侧提前释放了 AuthorizationRef,见第 4.4 节第 4 条 | -| 界面显示「特权组件需要更新」 | 改过 XPC 协议但没重装 helper,点「更新特权组件」(或跑安装脚本) | -| 点「安装特权组件」报「不带安装载荷」 | 跑的是 `swift run` 的裸可执行文件,载荷只在 build-gui.sh 组装的 .app 里;用终端脚本装 | -| 授权框只有密码、没有指纹 | 这是系统 API 的限制,不是缺陷,见第 4.5 节 | -| 设备拔掉重插后点「连接」报「会话已经在运行了」 | helper 是旧版(失败态旧会话未被清理),重装 helper | -| 双击访达图标没反应 | 已修复:`applicationShouldHandleReopen` 里重新呈现主窗口(AppKit 自管窗口之后才拿得到这个回调) | +| The interface keeps showing "The privileged helper must be installed" | `launchctl print system/com.tetherkit.helper` | +| The helper does not come up | `/var/log/tetherkit-helper.log` | +| The connection fails | the "Log" panel in the interface (one click to copy) | +| After the install it still depends on Homebrew | `otool -L dist/TetherKit.app/Contents/Frameworks/libtetherkit.0.*.dylib` | +| Clicking "Connect" reports "Cannot restore the authorization credential (-60005)" | The App side released the AuthorizationRef too early, see item 4 in section 4.4 | +| The interface shows "The privileged helper needs updating" | The XPC protocol was changed but the helper was not reinstalled; click "Update the helper" (or run the install script) | +| Clicking "Install the helper" reports "This copy of TetherKit carries no installer payload" | The bare executable of `swift run` is running; the payload exists only in the .app assembled by build-gui.sh; install it with the terminal script | +| The authorization box only takes a password, no fingerprint | This is a limitation of the system API, not a defect, see section 4.5 | +| After unplugging and replugging the device, clicking "Connect" reports "A session is already running" | The helper is an old version (a failed state old session was not cleaned up); reinstall the helper | +| Double-clicking the Finder icon does nothing | Already fixed: the main window is presented again in `applicationShouldHandleReopen` (this callback only became available once AppKit managed the window itself) | diff --git a/docs/GUI-SPIKE.md b/docs/GUI-SPIKE.md index 728ba59..9936ade 100644 --- a/docs/GUI-SPIKE.md +++ b/docs/GUI-SPIKE.md @@ -1,426 +1,524 @@ -# GUI 与提权可行性验证 +# GUI and privilege escalation feasibility spike -本文记录一次探索性验证的结论:**能不能在 TetherKit 之上做一个原生 Swift 图形界面, -并让它以合适的方式取得配置网络所需的 root 权限。** +This document records the conclusions of an exploratory spike: **can a native +Swift GUI be built on top of TetherKit, and can it obtain the root privileges +needed to configure the network in an appropriate way?** -结论是能,但过程中排除掉了三条看起来更"正统"的路线。**被排除的路线和排除理由, -比最终方案本身更值得记录** —— 否则下次还会有人再走一遍。 +The answer is yes, but three routes that looked more "orthodox" were ruled out +along the way. **The ruled-out routes and the reasons for ruling them out are +worth recording more than the final design itself** -- otherwise someone will +walk the same path again next time. -所有结论都在 macOS 26.6 / Apple Silicon / Xcode 26.6 / Swift 6.3.3 上实测。 +All the conclusions were measured on macOS 26.6 / Apple Silicon / Xcode 26.6 / +Swift 6.3.3. --- -## 1. 结论速览 +## 1. Summary of the conclusions -| 能力 | 结论 | 依据 | +| Capability | Conclusion | Evidence | |---|---|---| -| C API 现状 | **不存在** | 全仓库零 `extern "C"`,只有 C++23 头 + CLI | -| Swift 调用 C++ | 不可行 | `std::expected` / `std::span` / 抽象基类,Swift interop 吞不下 | -| USB 枚举 | 不需要 root | 实测通过 | -| 创建 feth | 需要 root | 实测通过 | -| 配 IP(`SIOCAIFADDR`) | 可行,configd 不干预 | 实测通过 | -| 子网/具体路由 | 可行 | 实测通过 | -| DHCP | **系统客户端可用** | 实测拿到租约,`State: BOUND` | -| DNS | **自动配好**(scoped) | 实测 | -| scoped 默认路由 | **自动配好** | 实测 | -| 抢全局默认路由 | 需手工一步 | 仅当有更高优先级服务在跑时 | +| C API status (at the time of the spike) | **does not exist** | zero `extern "C"` in the whole repo, only C++23 headers + a CLI | +| Swift calling C++ | not feasible | `std::expected` / `std::span` / abstract base classes, which the Swift interop cannot swallow | +| USB enumeration | no root needed | measured, works | +| Creating feth | root needed | measured, works | +| Configuring the IP (`SIOCAIFADDR`) | feasible, configd does not interfere | measured, works | +| Subnet / specific routes | feasible | measured, works | +| DHCP | **the system client works** | measured a lease, `State: BOUND` | +| DNS | **set up automatically** (scoped) | measured | +| scoped default route | **set up automatically** | measured | +| Taking over the global default route | one manual step needed | only when a service of higher priority is running | --- -## 2. 核心事实:feth 没有 IOKit 节点 +## 2. The core fact: feth has no IOKit node -这一条是后面一连串限制的**共同根因**。 +This single fact is the **common root cause** of the whole chain of limitations +that follows. -`if_fake`(feth)通过 `SIOCIFCREATE` → `ifnet_attach` 创建,**不注册任何 IOKit -provider**,因此在 IORegistry 里没有 `IONetworkInterface` 节点。 +`if_fake` (feth) is created through `SIOCIFCREATE` -> `ifnet_attach` and +**registers no IOKit provider at all**, therefore there is no +`IONetworkInterface` node in the IORegistry. -实测对照: +Measured comparison: ``` -IOKit IONetworkInterface:en0–en7、anpi0/1/3、vmenet0/1 ← 无 feth -NetworkInterfaces.plist: feth 出现 0 次 -ipconfig getiflist: en4 en5 en6 bridge0 en0 en7 ← 无 feth -SC 动态存储: State:/Network/Interface/feth0/Link ← 有! +IOKit IONetworkInterface: en0-en7, anpi0/1/3, vmenet0/1 <- no feth +NetworkInterfaces.plist: feth appears 0 times +ipconfig getiflist: en4 en5 en6 bridge0 en0 en7 <- no feth +SC dynamic store: State:/Network/Interface/feth0/Link <- yes! ``` -最后一行是关键反差:configd 的**动态存储**能看见 feth 的链路状态(那一层由路由 -socket / KEV 事件喂养),但 **SCNetworkInterface 服务层看不见**(那一层枚举 IOKit)。 +The last line is the key contrast: configd's **dynamic store** can see feth's +link state (that layer is fed by routing socket / KEV events), but the +**SCNetworkInterface service layer cannot** (that layer enumerates IOKit). -一句话:**只读的链路状态拿得到,可写的服务配置拿不到。** +In one sentence: **the read-only link state is obtainable, the writable service +configuration is not.** -注意 `vmenet0/1`(Virtualization.framework)**在** IOKit 里 —— 虚拟接口本身没问题, -问题是 `if_fake` 这种纯 ifnet 附着的方式没有 IOKit 存在感。 +Note that `vmenet0/1` (Virtualization.framework) **is** in IOKit -- virtual +interfaces themselves are fine; the problem is that `if_fake`, being attached +purely at the ifnet level, has no IOKit presence. --- -## 3. 被排除的三条路线 +## 3. The three routes that were ruled out -### 3.1 SCPreferences 建持久网络服务 ❌ +### 3.1 SCPreferences for a persistent network service -想法:用 `SCPreferences` + `SCNetworkService` 建一个真正的网络服务,让 macOS 自己 -跑 DHCP、管 DNS、算路由。 +The idea: use `SCPreferences` + `SCNetworkService` to create a real network +service and let macOS run the DHCP, handle the DNS and compute the routes +itself. -排除理由:`SCNetworkInterfaceCopyAll()` 枚举的是 IOKit 节点,feth 不在其中, -无法为它创建 `SCNetworkService`。 +The reason: `SCNetworkInterfaceCopyAll()` enumerates IOKit nodes, feth is not +among them, and no `SCNetworkService` can be created for it. -要让 SC 看见 feth,得写 IOKit driver —— 那正是本项目要避免的东西。 +For SC to see feth, an IOKit driver would have to be written -- which is +exactly what this project wants to avoid. -### 3.2 SCDynamicStore 凭空注入服务 ❌ +### 3.2 Injecting a service into the SCDynamicStore out of thin air -想法:绕开偏好设置层,直接往动态存储写 `State:/Network/Service//IPv4` 和 -`/DNS`(VPN 客户端的常见手法),让 configd 自己把 feth 算成一个服务。 +The idea: bypass the preferences layer, write `State:/Network/Service//IPv4` +and `/DNS` straight into the dynamic store (the common trick of VPN clients) +and let configd work out that feth is a service. -实测过程: +The measured process: -1. 只写 `/DNS` → 不被采纳 -2. 补齐 `/IPv4`(含 `Addresses`、`SubnetMasks`、`InterfaceName`),且接口和地址 - 都真实存在 → **仍不被采纳** +1. Writing only `/DNS` -> not adopted +2. Completing `/IPv4` (with `Addresses`, `SubnetMasks`, `InterfaceName`), and + both the interface and the address really exist -> **still not adopted** -`scutil --dns` 里始终找不到注入的解析器;`State:/Network/Global/IPv4` 的 -`PrimaryService` 是个 UUID,说明 IPMonitor 只认从 `Setup:` 层实例化出来的服务。 +The injected resolver never shows up in `scutil --dns`; the `PrimaryService` in +`State:/Network/Global/IPv4` is a UUID, which shows that IPMonitor only +accepts the services instantiated from the `Setup:` layer. -而写 `Setup:` 层要回到 3.1 的死结。 +And writing the `Setup:` layer leads back to the dead end of 3.1. -⚠️ **这条的适用范围很窄,别推广。** 它只证明"**手工捏造**的动态存储条目不被采纳"。 +Warning: **the scope of this finding is very narrow, do not generalize it.** It +only proves that the "**hand-fabricated**" dynamic store entries are not +adopted. -实测反证:`ipconfig set feth0 DHCP` 之后,IPConfiguration 发布了 -`State:/Network/Service/DHCP-feth0/{DHCP,DNS,IPv4}` —— **和这里手工写失败的是同样的键** —— -IPMonitor 完全采纳(DNS 生效、默认路由装上)。见 §6.3。 +Measured counter-evidence: after `ipconfig set feth0 DHCP`, IPConfiguration +published `State:/Network/Service/DHCP-feth0/{DHCP,DNS,IPv4}` -- **exactly the +same keys that failed here when they were written by hand** -- and IPMonitor +adopts them completely (the DNS takes effect, the default route is installed). +See section 6.3. -差别在于服务是否经正规路径注册,不在于键的内容。所以 feth 上的 configd 集成 -**是可行的**,只是不能用手写动态存储这条捷径。 +The difference lies in whether the service was registered through the proper +path, not in the content of the keys. So the configd integration on feth **is +feasible**, it just cannot use the shortcut of hand-writing the dynamic store. -### 3.3 SMAppService 安装特权 helper ❌ +### 3.3 SMAppService for installing the privileged helper -想法:用 macOS 13+ 的现代 API 注册 LaunchDaemon。 +The idea: register the LaunchDaemon with the modern API of macOS 13+. -排除理由与技术无关,与**分发方式**有关:`SMPrivilegedExecutables` / -`SMAuthorizedClients` 靠代码签名的 designated requirement 双向绑定。源码分发 -(Homebrew formula)下每台机器编译出的 cdhash 都不同,写死在 plist 里的 DR -必然对不上。 +The reason has nothing to do with the technology and everything to do with the +**distribution method**: `SMPrivilegedExecutables` / `SMAuthorizedClients` +bind in both directions through the code signature's designated requirement. +Under source distribution (a Homebrew formula) the cdhash produced on each +machine differs, so the DR hard-coded in the plist can never match. -附带:SMAppService **不弹密码框** —— 审批入口是「系统设置 → 登录项与扩展」。 -这是 Apple 从 macOS 13 起的刻意设计,会弹密码框的 `SMJobBless` 已废弃。 +As a side note: SMAppService **does not pop a password box** -- the approval +entry point is "System Settings -> Login Items & Extensions". This is a +deliberate design of Apple since macOS 13; `SMJobBless`, which did pop a +password box, is deprecated. --- -## 4. 最终方案 +## 4. The final design ``` -TetherKit.app (uid 501,不需要 root) - │ ① 直接调 C API 做免 root 的事:环境预检、USB 枚举 - │ ② AuthorizationCopyRights → 弹指纹/密码 → AuthorizationRef - │ ③ AuthorizationMakeExternalForm → 32 字节凭据 - │ - ├── XPC(com.tetherkit.helper,.privileged)──► tetherkit-helper (uid 0) - │ │ ④ CreateFromExternalForm 还原 - │ │ ⑤ CopyRights 复核 - │ │ (**不带** interactionAllowed) - │ │ ⑥ 复核通过才执行特权操作 - └────────────────────── 结果 ◄───────────────────┘ +TetherKit.app (uid 501, no root needed) + | 1. call the C API directly for the root-free work: environment preflight, USB enumeration + | 2. AuthorizationCopyRights -> pop a fingerprint/password -> AuthorizationRef + | 3. AuthorizationMakeExternalForm -> 32-byte credential + | + +-- XPC (com.tetherkit.helper, .privileged) --> tetherkit-helper (uid 0) + | | 4. CreateFromExternalForm restores it + | | 5. CopyRights re-checks it + | | (without interactionAllowed) + | | 6. only after the re-check passes does it do the privileged work + +-------------------- result <----------------------+ ``` -helper 由传统 LaunchDaemon 拉起(`/Library/LaunchDaemons/` + `MachServices`), -按需启动,不需要 `KeepAlive`。 - -### 为什么安全性靠 AuthorizationRef 而不是代码签名 - -helper 的 root **来自 launchd**,跟用户按没按指纹毫无关系 —— 它一启动就是 root, -任何能连上 Mach 服务的进程都能发请求。所以"谁在调用"必须由 helper 自己回答。 - -两种答法: - -- **校验调用方代码签名**(SMJobBless 的做法)—— 依赖证书,源码分发不可行 -- **要求每次调用附带用户刚确认过的 AuthorizationRef** —— 不依赖任何证书 - -选后者。对开源、源码分发的工具这是更合适的模型:谁编译的都一样安全。 - -### 三条容易写错的细节 - -1. **复核时绝不能带 `interactionAllowed`。** daemon 没有 UI 会话;真让它能弹框, - 等于任何进程都能随意触发系统授权弹框骚扰用户。那一步只查"这份凭据里已经有 - 这项权利了吗",不获取新权利。 -2. **`AuthorizationFree` 不能带 `destroyRights`。** 凭据归 App 所有,helper 只是 - 借来核对;带了会把 App 那边的授权一起作废。 -3. **连通性探测方法不要求授权。** 否则"helper 没装"和"授权没过"两种失败混在一起, - 没法给用户准确提示。 - -### 后补:helper 的安装本身也能走同一套授权(AEWP) - -后来把「安装 helper」也从终端挪进了 App(`HelperInstaller.swift`)。机制是 -废弃已久的 `AuthorizationExecuteWithPrivileges`,macOS 26 实测: - -- `dlsym(RTLD_DEFAULT, "AuthorizationExecuteWithPrivileges")` 仍能解析; -- setuid 的执行载体 `/usr/libexec/security_authtrampoline` 仍在(mode 04711)。 - -它恰好接 `AuthorizationRef`,与 4 节的信任模型同源 —— App 缓存的令牌直接复用, -5 分钟窗口内安装一次框都不弹。`osascript with administrator privileges` 因为 -自建授权会话、接不上令牌缓存而被排除。三个实测约束: - -- **AEWP 的执行对象必须是二进制,不能是 shell 脚本。** trampoline 给子进程的 - 凭据是 euid=0 / **ruid=调用者**,而 bash(zsh、dash 同理)在 euid ≠ ruid 且 - 未带 `-p` 时会把 euid 降回 ruid —— 防 setuid 脚本的老规矩。直接 AEWP - 安装脚本,真机上得到的就是脚本自己的「需要 root 权限」报错。解法:AEWP - helper 二进制的 `--install` 模式,先 `setgid(0)` + `setuid(0)` 归一化成与 - sudo 完全相同的凭据,再 exec 脚本(`InstallerMode.swift`)。 -- **不给 pid 也不给退出码**,只有连到子进程 stdout 的管道 —— stderr 要在 - 子进程里 dup2 并流,成败得用「装完能否连上匹配版本的 helper」这类真实 - 结果判定; -- 用 dlsym 而不是直接链接:将来符号被移除时能降级成终端引导,而不是 - dyld 绑定失败。 +The helper is launched by a traditional LaunchDaemon +(`/Library/LaunchDaemons/` + `MachServices`), it is started on demand and +needs no `KeepAlive`. + +### Why the security rests on the AuthorizationRef rather than on the code signature + +The helper's root **comes from launchd** and has nothing to do with whether the +user touched their fingerprint -- it is root from the moment it starts, and any +process that can reach the Mach service can send requests. So "who is calling" +has to be answered by the helper itself. + +Two ways to answer it: + +- **Verify the code signature of the caller** (the SMJobBless approach) -- + depends on a certificate, which does not work under source distribution +- **Require every call to carry the AuthorizationRef that the user has just + confirmed** -- depends on no certificate at all + +The latter is chosen. For an open-source, source-distributed tool this is the +more appropriate model: whoever built it, it is equally safe. + +### Three details that are easy to get wrong + +1. **The re-check must never carry `interactionAllowed`.** A daemon has no UI + session; letting it pop a dialog would let any process trigger the system + authorization dialog at will to harass the user. That step only asks "does + this credential already contain this right?", it does not acquire new rights. +2. **`AuthorizationFree` must not carry `destroyRights`.** The credential + belongs to the App, the helper only borrows it to check; carrying it would + void the App's authorization as well. +3. **The connectivity probe methods do not require authorization.** Otherwise + the two failures "the helper is not installed" and "the authorization did not + pass" get mixed together and the user cannot be given an accurate message. + +### Addendum: installing the helper itself can go through the same authorization (AEWP) + +Later on, "installing the helper" was moved from the terminal into the App as +well (`HelperInstaller.swift`). The mechanism is the long-deprecated +`AuthorizationExecuteWithPrivileges`, measured on macOS 26: + +- `dlsym(RTLD_DEFAULT, "AuthorizationExecuteWithPrivileges")` still resolves; +- the setuid trampoline `/usr/libexec/security_authtrampoline` is still there + (mode 04711). + +It happens to take an `AuthorizationRef`, so it has the same origin as the trust +model of section 4 -- the token cached by the App is reused directly, and +installing pops no dialog at all within the 5-minute window. +`osascript with administrator privileges` was ruled out because it creates its +own authorization session and cannot hook into the token cache. Three measured +constraints: + +- **The execution target of AEWP must be a binary, not a shell script.** The + credential that the trampoline gives the child process is euid=0 / + **ruid=the caller**, and bash (zsh and dash likewise) drops the euid back to + the ruid when euid != ruid and `-p` is not given -- the old rule against + setuid scripts. AEWP-ing the install script directly yields, on a real + machine, the script's own "root privileges required" error. The solution: the + `--install` mode of the AEWP helper binary, which first normalizes the + credential with `setgid(0)` + `setuid(0)` into exactly the same credential as + sudo, and then execs the script (`InstallerMode.swift`). +- **Neither a pid nor an exit code is given**, only a pipe connected to the + child's stdout -- stderr has to be dup2-ed into the stream inside the child, + and the success has to be judged by a real result such as "after the install, + can we connect to a helper of the matching version"; +- use dlsym instead of linking directly: if the symbol is removed in the + future it can degrade into a terminal guide instead of a dyld binding failure. --- -## 5. 关键澄清:授权 ≠ root +## 5. The key clarification: authorization != root -`AuthorizationCopyRights` 成功**不改变进程的任何东西**。它产出的 -`AuthorizationRef` 是一张**凭据**,语义是"用户在某时刻通过密码/指纹确认了, -同意授予某项权利"。进程的 uid、euid 一个都没动。 +A successful `AuthorizationCopyRights` **changes nothing about the process**. The +`AuthorizationRef` it produces is a **credential**, whose semantics are "the +user confirmed at some moment with a password / fingerprint that they agree to +grant a certain right". Not one of the uid and euid of the process moves. -实测:App 在授权成功的下一行代码直接调 `tk_feth_create()`,仍然 `EPERM`; -同一个函数经 helper 调用则成功。 +Measured: on the line right after the authorization succeeds, the App calls +`tk_feth_create()` directly and still gets `EPERM`; the same function called +through the helper succeeds. -Authorization Services 从来就是**咨询式**授权系统,服务对象是那些本来就有 root -的组件。权限本身必须另有来源(launchd 启动的 daemon、setuid 二进制)。 +The Authorization Services have always been an **advisory** authorization +system, serving the components that already have root. The privilege itself +must come from somewhere else (a daemon started by launchd, a setuid binary). -**顺序不能反**:不能"先弹指纹拿到 root,再去建网卡",只能"先有常驻的 root helper, -每次操作弹指纹拿凭据交给它复核"。 +**The order cannot be reversed**: you cannot "pop the fingerprint to get root +and then create the interface"; it can only be "have a resident root helper +first, pop the fingerprint for each operation and hand the credential to it for +re-checking". --- -## 6. 网络配置能力边界 +## 6. The boundaries of the network configuration capability -### 6.1 可行(纯内核 ioctl,configd 不参与) +### 6.1 Feasible (pure kernel ioctl, configd not involved) -- `SIOCAIFADDR` 配地址 —— 实测配上后 5 秒仍在,configd 不来收尸 -- 地址配好后子网路由自动生成 -- scoped 默认路由(`RTF_IFSCOPE`)—— macOS 支持每接口独立的默认路由, - 实测一台机器上同时存在 4 条 default(ipsec0 / en0 / bridge100 / bridge101) +- `SIOCAIFADDR` to configure an address -- measured: it is still there 5 + seconds later, configd does not come to clean it up +- once the address is configured, the subnet route is generated automatically +- scoped default route (`RTF_IFSCOPE`) -- macOS supports a separate default + route per interface; measured: 4 default routes coexisted on one machine + (ipsec0 / en0 / bridge100 / bridge101) -configd 看不见 feth 只影响**它管不了 feth**,不影响**你自己管**。 +configd not seeing feth only means **it cannot manage feth**, it does not +affect **managing it yourself**. -### 6.2 DHCP:系统客户端可用 +### 6.2 DHCP: the system client works -**IPConfiguration 接受 feth**,不需要自己实现 DHCP 客户端。 +**IPConfiguration accepts feth**, there is no need to implement a DHCP client +ourselves. ```bash sudo ipconfig set feth9 DHCP # exit=0 -ipconfig getiflist # → ... feth9 ← 出现了 -ipconfig getsummary feth9 # → ConfigMethod: DHCP, ServiceID: DHCP-feth9 +ipconfig getiflist # -> ... feth9 <- it is there +ipconfig getsummary feth9 # -> ConfigMethod: DHCP, ServiceID: DHCP-feth9 ``` -⚠️ **一个曾经把结论带偏的陷阱**:`ipconfig getiflist` 列的是「**当前有服务的接口**」, -不是「能配置的接口」。没跑过服务的接口一律不在里面 —— 连插着线的真网卡也不在 -(实测 `ipconfig getsummary en1` 同样报 "interface doesn't exist",而 en1 是块 -正经的 Thunderbolt 网卡)。**不能拿它的缺席当作"IPConfiguration 不认这个接口"的证据。** +Warning: **a trap that once led the conclusion astray**: `ipconfig getiflist` +lists the "**interfaces that currently have a service**", not the "interfaces +that can be configured". An interface that has never run a service is never in +it -- not even a real wired NIC (measured: `ipconfig getsummary en1` likewise +reports "interface doesn't exist", and en1 is a perfectly ordinary Thunderbolt +NIC). **Its absence must not be taken as evidence that "IPConfiguration does +not recognize this interface".** -链路必须是 up 的:feth 没有 peer 时 `status: inactive`,服务会停在 -`Active: FALSE` / `State: INACTIVE`。真实场景下 feth0↔feth1 已配对,链路 active。 +The link must be up: while feth has no peer, `status: inactive` and the +service stops at `Active: FALSE` / `State: INACTIVE`. In a real scenario +feth0<->feth1 are already paired and the link is active. -**代价**:`ipconfig set` 建立的是**临时服务**,只活到下一次网络配置变更,且不出现在 -「系统设置 → 网络」里。对 GUI 反而好办 —— App 自己就是配置入口,服务掉了可以重新 set。 +**The cost**: `ipconfig set` creates a **temporary service**, it lives only +until the next network configuration change and does not appear in "System +Settings -> Network". For the GUI this is actually easier -- the App is the +configuration entry point itself, and when the service is gone it can simply +set it again. -### 6.3 一条命令换来的东西 +### 6.3 What one command buys you -在真实链路(feth0↔feth1 已配对、设备侧有 DHCP 服务器)上执行: +Executed on a real link (feth0<->feth1 paired, the device side running a DHCP +server): ```bash sudo ipconfig set feth0 DHCP ``` -**IPConfiguration 会自动完成全部四件事**: +**IPConfiguration automatically does all four things**: -| 结果 | 实测证据 | +| Result | Measured evidence | |---|---| -| 拿到租约 | `ipconfig getifaddr feth0` → `192.168.35.128`,`State: BOUND` | -| 配好 DNS(scoped) | `scutil --dns` 出现 `nameserver 192.168.35.7 / if_index 43 (feth0) / Scoped` | -| 装好 scoped 默认路由 | `netstat -rn` 出现 `default 192.168.35.7 UGScIg feth0` | -| 发布到动态存储 | `State:/Network/Service/DHCP-feth0/{DHCP,DNS,IPv4}`,`IsPublished: TRUE` | +| A lease is obtained | `ipconfig getifaddr feth0` -> `192.168.35.128`, `State: BOUND` | +| The DNS is configured (scoped) | `scutil --dns` shows `nameserver 192.168.35.7 / if_index 43 (feth0) / Scoped` | +| The scoped default route is installed | `netstat -rn` shows `default 192.168.35.7 UGScIg feth0` | +| It is published to the dynamic store | `State:/Network/Service/DHCP-feth0/{DHCP,DNS,IPv4}`, `IsPublished: TRUE` | -最后一行值得注意:**这正是 §3.2 里手工写失败的那三个键**。IPConfiguration 通过 -正规路径注册的服务,IPMonitor 完全采纳;手工捏造的不采纳。 +The last row is worth noting: **these are exactly the three keys that failed +when they were written by hand in section 3.2**. A service registered by +IPConfiguration through the proper path is adopted completely by IPMonitor; a +hand-fabricated one is not. -### 6.4 唯一需要手工的一步:抢全局默认路由 +### 6.4 The only manual step: taking over the global default route -feth0 **不会**自动成为主服务。实测时 `State:/Network/Global/IPv4` 的 -`PrimaryInterface` 仍是 `ipsec0`(当时 VPN + Wi-Fi 都在跑,排名高于 feth0)。 +feth0 **does not** become the primary service automatically. In the measurement +the `PrimaryInterface` in `State:/Network/Global/IPv4` was still `ipsec0` (at +the time both a VPN and Wi-Fi were running, ranked above feth0). -结果是:feth0 有 **scoped** 默认路由(绑定到该接口的流量走它),但**未绑定的流量** -仍走主服务。 +The result: feth0 has a **scoped** default route (the traffic bound to that +interface goes through it), but the **unbound traffic** still goes through the +primary service. -要让全局流量走设备,就是那条原本的命令: +To make the global traffic go through the device, it is the original command: ```bash sudo route -n change default $(ipconfig getoption feth0 router) ``` -**注意适用条件**:只在有更高优先级服务同时具备连通性时才需要这一步。而 USB 网络 -共享的典型场景恰恰是没有别的网络可用(用户正因为没 Wi-Fi 才插的手机),那时 feth0 -大概率自然成为主服务,这条命令都不用跑。 +**Mind the conditions of applicability**: this step is only needed when a +service of higher priority is running and also has connectivity. And the +typical scenario of USB network sharing is precisely the one where no other +network is available (the user plugged in the phone precisely because there is +no Wi-Fi); there feth0 most likely becomes the primary service on its own and +the command does not even have to run. -### 6.5 对实现的影响 +### 6.5 The impact on the implementation -**`tk_net_*` 那组 C API 基本不需要了。** 原计划要自己实现的 DHCP 客户端 -(在 feth0 上开 BPF 走 DISCOVER/OFFER/REQUEST/ACK,还要和 RNDIS 状态机做时序耦合) -整块省掉。helper 只需要: +**The `tk_net_*` group of C APIs is basically not needed.** The DHCP client +originally planned to be implemented by hand (opening BPF on feth0 to run +DISCOVER/OFFER/REQUEST/ACK, and coupling its timing to the RNDIS state machine) +is saved as a whole. The helper only needs: -1. `ipconfig set DHCP` —— 或者用静态 IP 时走 §6.1 的 `SIOCAIFADDR` -2. 可选地 `route change default` +1. `ipconfig set DHCP` -- or, for a static IP, the `SIOCAIFADDR` of + section 6.1 +2. optionally `route change default` -**代价**:`ipconfig set` 建立的是**临时服务**,只活到下一次网络配置变更,且不出现在 -「系统设置 → 网络」里。对 GUI 反而好办 —— App 自己就是配置入口,服务掉了重新 set 即可。 +**The cost**: `ipconfig set` creates a **temporary service**, it lives only +until the next network configuration change and does not appear in "System +Settings -> Network". For the GUI this is actually easier -- the App is the +configuration entry point itself, and when the service is gone, set it again. -**清理**:`sudo ipconfig set feth0 NONE`。 +**Cleanup**: `sudo ipconfig set feth0 NONE`. --- -## 7. 踩过的坑 +## 7. The pitfalls hit along the way -### Hardened Runtime 与 Homebrew 的 libusb +### Hardened Runtime and Homebrew's libusb -签名的 .app 启动即崩: +The signed .app crashes on startup: ``` Library not loaded: /opt/homebrew/opt/libusb/lib/libusb-1.0.0.dylib Reason: ... have different Team IDs ``` -Hardened Runtime 下 macOS 拒绝加载 Team ID 与主程序不一致的库。Homebrew 的 libusb -是 ad-hoc 签名(无 Team ID),而 App 用开发者证书签(有 Team ID)。 +Under the Hardened Runtime macOS refuses to load a library whose Team ID +differs from the main program's. Homebrew's libusb is ad-hoc signed (no Team +ID), while the App is signed with a developer certificate (it has a Team ID). -**任何签名分发的 TetherKit.app 都必须内嵌 libusb** 并用同一身份重签。 -另一条路是加 `com.apple.security.cs.disable-library-validation` 豁免,但那等于把 -整个进程的库校验关掉,不划算。 +**Any signed and distributed TetherKit.app must embed libusb** and re-sign it +with the same identity. The other road is to add the +`com.apple.security.cs.disable-library-validation` exemption, but that amounts +to turning off the library validation of the whole process, which is not worth +it. -注意:这个坑是**签名带来的**。源码构建的 ad-hoc 产物不存在此问题。 +Note: this pitfall is **brought about by the signing**. An ad-hoc artifact +built from the source does not have this problem. -### `install_name_tool` 之后必须重签 +### After `install_name_tool` you must re-sign -arm64 要求可执行文件有有效签名,改字节会让签名失效,然后启动直接被内核拒绝 -(`Killed: 9`),而且 launchd 日志里看不出原因。 +arm64 requires the executable to have a valid signature; changing the bytes +invalidates the signature, and then the startup is rejected outright by the +kernel (`Killed: 9`), with no reason visible in the launchd log. -### `install` 会把软链拆成独立文件 +### `install` breaks the symlinks into separate files -`libtetherkit.dylib` 和 `libtetherkit.0.dylib` 是指向 `.0.1.1.dylib` 的软链。 -`install` 不保留软链,会各拷一份**独立的真实文件**。后续 `install_name_tool` -只改到其中一份,而按 `@rpath` 加载的恰好是没改到的那份 —— **修了个不被使用的副本, -问题却还在,且毫无征兆**。用 `cp -a`。 +`libtetherkit.dylib` and `libtetherkit.0.dylib` are symlinks to +`.0.1.1.dylib`. `install` does not preserve the symlinks and copies a +**separate real file** for each. The later `install_name_tool` only changes one +of them, and the one that gets loaded via `@rpath` is exactly the one that was +not changed -- **a copy that is never used got fixed, the problem is still +there, and with no warning whatsoever**. Use `cp -a`. -### 依赖要对着正确的对象查 +### Look up the dependencies against the right object -`libusb` 是 `libtetherkit.dylib` 的依赖,不是 helper 可执行文件的。对着 helper 查 -`otool -L` 得到空结果,整段处理被静默跳过 —— 装完看起来一切正常,实际仍依赖 Homebrew。 +`libusb` is a dependency of `libtetherkit.dylib`, not of the helper +executable. Running `otool -L` against the helper yields an empty result, the +whole step is silently skipped -- after the install everything looks perfectly +normal, yet it still depends on Homebrew. -### UTF-8 locale 下 `$VAR` 紧跟全角标点会炸 +### Under a UTF-8 locale, `$VAR` followed by full-width punctuation blows up ```bash -echo "已清理(条目 + $IFACE)" # → IFACE?: unbound variable +echo "cleaned(items + $IFACE)" # -> IFACE?: unbound variable ``` -bash 按字节判断标识符字符,UTF-8 locale 下全角标点的首字节(0xEF)被 `isalnum()` -判为真,吸进了变量名。C locale 下不复现。 +bash decides the characters of an identifier byte by byte; under a UTF-8 +locale the lead byte of the full-width punctuation (0xEF) is judged true by +`isalnum()` and is absorbed into the variable name. It does not reproduce under +the C locale. -**规则:变量后面紧跟中文标点时必须写 `${VAR}`。** +**Rule: when a variable is immediately followed by Chinese punctuation, it must +be written as `${VAR}`.** -### 部署目标默认跟宿主走 +### The deployment target follows the host by default -项目支持传 `CMAKE_OSX_DEPLOYMENT_TARGET` 但没设默认值,产物一直跟着宿主编译 -(本次是 26.0),而 README 声称支持 13.3+。 +The project accepts `CMAKE_OSX_DEPLOYMENT_TARGET` but sets no default, so the +artifacts have always been built against the host version (26.0 in this case), +while the README claims support for 13.3+. -顺带发现:Homebrew 的 libusb 本身也是按宿主版本编的,所以**预编译 Release 二进制 -实际上跑不了 13.3**。这个与 GUI 无关,但影响现有的 Release 产物。 +Incidentally: Homebrew's libusb itself is also built against the host version, +so **the prebuilt Release binaries actually do not run on 13.3**. This has +nothing to do with the GUI, but it does affect the existing Release artifacts. --- -## 8. Homebrew 分发 +## 8. Homebrew distribution -### 实测的两条约束 +### The two measured constraints -- **`brew services` 的 service DSL 没有 `MachServices`**(查 - `Library/Homebrew/service.rb` 确认),注册不了 XPC 服务 -- **`brew install` 从不以 root 运行**,formula 写不了 `/Library/LaunchDaemons` +- **The service DSL of `brew services` has no `MachServices`** (confirmed by + looking at `Library/Homebrew/service.rb`), so an XPC service cannot be + registered +- **`brew install` never runs as root**, a formula cannot write + `/Library/LaunchDaemons` -→ helper 只能装进 prefix,由 caveats 引导用户跑一条 `sudo`。这是标准做法。 +So the helper can only be installed into the prefix, and the caveats guide the +user to run one `sudo`. This is the standard practice. -### .app 的位置 +### Where the .app goes -formula **可以**装 .app 进 Cellar(`python@3.14` 就装了两个),但**不会**出现在 -`/Applications`、Launchpad 或聚焦搜索里 —— 需要 caveats 引导用户软链,或改走 Cask。 +A formula **can** install an .app into the Cellar (`python@3.14` installs two), +but it **does not** show up in `/Applications`, in the Launchpad or in the +Spotlight search -- the caveats have to guide the user to create a symlink, or +the distribution has to switch to a Cask. -### 签名需求 +### The signing requirements -| 分发方式 | 需要证书吗 | +| Distribution method | Does it need a certificate? | |---|---| -| Formula(源码构建) | **不需要** —— 本地编译不带 quarantine,Gatekeeper 不参与 | -| Cask(预编译 .app) | 需要 Developer ID + 公证 —— Cask 默认给下载物加 quarantine | +| Formula (built from the source) | **no** -- a local build carries no quarantine, Gatekeeper is not involved | +| Cask (prebuilt .app) | Developer ID + notarization needed -- a Cask puts the quarantine on the download by default | -源码构建路线之所以可行,正是因为安全模型选了 AuthorizationRef 复核而非签名绑定。 +The source-build route is feasible precisely because the security model chose +the AuthorizationRef re-check over the signature binding. --- -## 9. C API 缺口清单 +## 9. The C API gap list -若要继续,现有 C++ 层需要补的东西: +To go further, the existing C++ layer needs the following (as of the spike): -**必须新增的访问器** —— `Runtime` 目前只暴露 `SystemInterfaceName()` 和 -`DeviceInfo()`,以下全部拿不到:桥接统计、RNDIS 当前状态、链路 up/down、 -协商参数、驱动侧网卡名、致命错误原因。 +**The accessors that must be added** -- `Runtime` currently exposes only +`SystemInterfaceName()` and `DeviceInfo()`, and the following are all +unobtainable: the bridge statistics, the current RNDIS state, the link up/down, +the negotiated parameters, the driver side interface name, the reason for a +fatal error. -**必须改的行为**: +**The behaviours that must change**: -- `RunUntilStopped()` 是阻塞的,GUI 用不了 —— `Start()` 必须自己起控制线程后返回 - (控制通道必须在非 libusb 事件线程上,这条约束不能省) -- 观察者的五个回调现在只打日志,需要转发给宿主 +- `RunUntilStopped()` blocks and is unusable from a GUI -- `Start()` must start + the control thread itself and return (the control channel has to be on a + thread that is not the libusb event thread, this constraint cannot be + dropped) +- the five callbacks of the observer only log today and have to be forwarded to + the host -**建议用事件队列而非回调**:回调会从 libusb 事件线程和控制线程上来,Swift 侧要跨线程 -marshal;更要命的是重入 —— 在回调里调停机会自等死锁(`Stop()` 要 join 控制线程)。 +**An event queue is recommended rather than callbacks**: the callbacks arrive +from the libusb event thread and from the control thread, so the Swift side has +to marshal across the threads; worse is re-entrancy -- calling stop from inside +a callback self-waits into a deadlock (`Stop()` joins the control thread). -**其它缺口**: +**The other gaps**: -| 缺口 | 说明 | +| Gap | Explanation | |---|---| -| 日志 sink | 日志只往 stderr 走,GUI 拿不到 | -| 设备字符串描述符 | `DeviceCandidate` 无厂商名/产品名/序列号,两台同型号设备无法区分 | -| 热插拔 | `Context::SupportsHotplug()` 存在但从未被使用 | -| 孤儿 feth 清理 | 进程被 SIGKILL 时析构不跑,网卡留在内核里 | -| helper 信号处理 | `launchctl bootout` 发 SIGTERM,Swift `deinit` 不会跑,同样漏网卡 | +| Log sink | The logs only go to stderr, the GUI cannot get them | +| Device string descriptors | `DeviceCandidate` has no vendor name / product name / serial number, two devices of the same model cannot be told apart | +| Hot plug | `Context::SupportsHotplug()` exists but has never been used | +| Orphan feth cleanup | When the process is SIGKILLed the destructor does not run and the interface stays in the kernel | +| helper signal handling | `launchctl bootout` sends SIGTERM, the Swift `deinit` does not run, and the interface is leaked just the same | -孤儿清理是兜底,比信号处理更重要 —— SIGKILL 是任何信号处理器都拦不住的。 +The orphan cleanup is the backstop and matters more than the signal handling -- +SIGKILL is something that no signal handler can catch. --- -## 10. 验证命令备忘 +## 10. Verification command memo ```bash -# feth 是否在 IOKit 里(预期:不在) +# whether feth is in IOKit (expected: it is not) ioreg -c IONetworkInterface -r -d1 | grep '"BSD Name"' | sort -u -# IPConfiguration 认不认 feth(预期:认,exit=0 且随后出现在 getiflist 里) +# whether IPConfiguration accepts feth (expected: yes, exit=0 and it then shows up in getiflist) sudo ifconfig feth9 create sudo ipconfig set feth9 DHCP && ipconfig getiflist sudo ipconfig set feth9 NONE && sudo ifconfig feth9 destroy -# ⚠️ getiflist 列的是「当前有服务的接口」,不是「能配置的接口」。 -# 没跑过服务的接口一律不在里面,插着线的真网卡也不在。 -# 不要拿它的缺席当作「IPConfiguration 不认这个接口」的证据 —— 我踩过。 +# Warning: getiflist lists the "interfaces that currently have a service", not +# the "interfaces that can be configured". An interface that has never run a +# service is never in it, not even a real wired NIC. +# Do not take its absence as evidence that "IPConfiguration does not +# recognize this interface" -- I stepped on that one. -# 真实链路下的完整验证(需要接着设备、tetherkit 在跑) +# the complete verification on a real link (needs the device plugged in and tetherkit running) sudo ipconfig set feth0 DHCP -ipconfig getifaddr feth0 # 租约 -scutil --dns | grep -A3 feth0 # scoped DNS(在后段,别用 head 截断) -netstat -rn -f inet | grep -E "^default" # scoped 默认路由 +ipconfig getifaddr feth0 # the lease +scutil --dns | grep -A3 feth0 # scoped DNS (it is in the later part, do not cut it with head) +netstat -rn -f inet | grep -E "^default" # scoped default route ipconfig getsummary feth0 | grep -E "State|IsPublished|Router" -sudo ipconfig set feth0 NONE # 清理 +sudo ipconfig set feth0 NONE # cleanup -# 内核肯不肯给 feth 配地址(预期:肯) +# whether the kernel agrees to configure an address on feth (expected: it does) sudo ifconfig feth9 create sudo ifconfig feth9 inet 10.99.99.1 netmask 255.255.255.0 up netstat -rn -f inet | grep 10.99.99 sudo ifconfig feth9 destroy -# Homebrew service DSL 支不支持 MachServices(预期:不支持) +# whether the Homebrew service DSL supports MachServices (expected: it does not) grep -i machservice /opt/homebrew/Library/Homebrew/service.rb ``` -`netstat -rn` 输出里接口路由行尾的 `!` 是 scoped route 标记(`RTF_IFSCOPE`), -**不是错误** —— 正常的 en0/bridge 路由也带它。 +The `!` at the end of the interface route lines in the `netstat -rn` output is +the scoped route marker (`RTF_IFSCOPE`), **not an error** -- the ordinary +en0/bridge routes carry it too. diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 8c1ce8c..7da58f3 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -1,178 +1,219 @@ -# 性能调优指南 +# Performance tuning guide -实测数字见 [BENCHMARKS.md](BENCHMARKS.md),架构理由见 [DESIGN.md](DESIGN.md)。 -本文只讲**怎么调**。 +Measured numbers are in [BENCHMARKS.md](BENCHMARKS.md); the architectural +rationale is in [DESIGN.md](DESIGN.md). This document only covers **how to +tune**. --- -## 一句话结论 +## The one-line conclusion -> **优化目标是每帧的系统调用次数,不是 CPU 周期。** +> **The optimization target is the number of system calls per frame, not CPU +> cycles.** -每帧的 CPU 成本合计约 70 ns,一次系统调用约 700 ns。macOS 上系统调用比 Linux -贵 3~10 倍,这是整个设计的第一约束。所以调优的全部着力点都在 -「让一次系统调用 / 一次 USB 往返承载更多帧」。 +Total CPU cost per frame is about 70 ns; one system call costs about 700 ns. On +macOS a system call costs 3~10x what it costs on Linux, and that is the first +constraint on the entire design. So every tuning lever is about "carrying more +frames per system call / per USB round trip". --- -## 吞吐的理论上限 +## Theoretical throughput ceiling -| 链路 | 理论有效载荷 | 1514 字节满帧的帧率 | +| Link | Theoretical payload | Frame rate at the 1514-byte full frame | |---|---|---| | USB 2.0 full-speed | ~12 Mbps | ~1 k pps | -| USB 2.0 high-speed | **~426 Mbps**(53.2 MB/s) | ~35 k pps | -| USB 3.0 SuperSpeed | ~4 Gbps(RNDIS 实际很难跑满) | ~330 k pps | +| USB 2.0 high-speed | **~426 Mbps** (53.2 MB/s) | ~35 k pps | +| USB 3.0 SuperSpeed | ~4 Gbps (RNDIS can hardly saturate it in practice) | ~330 k pps | -高速的 426 Mbps 推导:`wMaxPacketSize=512`,每 125 µs 微帧最多 13 个数据包 -→ 6656 B / 125 µs = 53.248 MB/s。 +Derivation of the high-speed 426 Mbps: `wMaxPacketSize=512`, at most 13 data +packets per 125 us microframe -> 6656 B / 125 us = 53.248 MB/s. -对照 `FrameRing` 跨线程搬运的 ~55 ns/帧(≈18 M 帧/秒),**无锁队列有两个数量级 -的余量**。瓶颈永远在 USB 与系统调用侧。 +Compared with `FrameRing`'s cross-thread handoff of ~55 ns/frame (~18 M +frames/s), **the lock-free queue has two orders of magnitude of headroom**. The +bottleneck is always on the USB and system call side. --- -## 调优旋钮(按收益排序) - -### 1. `--max-transfer-kb`(默认 16)——**收益最大** - -主机在 `INITIALIZE_MSG` 里宣称的 `MaxTransferSize`。这是让**设备**把多个 -`REMOTE_NDIS_PACKET_MSG` 聚合进一次 bulk IN 的**唯一**手段。 - -- 报得越大 → 设备聚合越多 → 每帧摊到的 USB 往返与系统调用越少。 -- Linux 只报 2048(一个满帧),非常保守。本项目默认 16 KiB。 -- 上限:规范要求 USB 1.1 设备不超过 `0x4000`(16 KiB)。 -- **代价**:bulk IN 缓冲会自动跟着放大(必须 ≥ 宣称值),内存占用 - = `rx-transfers × max-transfer-kb`。默认 16 × 16 KiB = 256 KiB。 - -⚠️ 但**设备可能不配合**:主线 Linux gadget 汇报 `MaxPacketsPerMessage = 1`, -即它一次只发一个包,报多大都没用。打了高通上行聚合补丁的 Android 内核才会报 3 -或更多。启动日志里会打印协商结果,看 `设备聚合上限 N 包` 就知道有没有效。 - -### 2. `--rx-transfers`(默认 16)与 `--rx-transfer-kb`(默认 16) - -并发在飞的 bulk IN 传输数与每个的大小。目标是**让 USB 控制器队列永不见底**。 - -- 在飞总量 = 两者相乘,默认 256 KiB。 -- 依据:高速下 53 MB/s,1 ms 的队列空档就浪费 50 KB。在飞总量 ≥ 256 KiB 才能 - 覆盖「用户态回调派发 + 重新 submit」的调度抖动。 -- **不要用「多而小」**:macOS 上每次 `libusb_submit_transfer` 至少 3 次 IOKit - 往返(darwin 后端在每次提交前都调 `darwin_get_pipe_properties`, - 在 `IOUSBInterfaceInterface >= 550` 上等于两次 user-client 调用)。 - 反过来 darwin 对单次 bulk 长度**没有**上限、不做分片 - (16 KB 分片是 Linux 后端的事,不是 macOS 的)。 - -### 3. `--bpf-buffer-kb`(默认 4096) - -BPF 内核抓包缓冲。内核会分配**两份**(store + free 双缓冲),所以实际 wired -内存是这个值的两倍。 - -- 上限有歧义:调研的两个来源分别指向 `debug.bpf_maxbufsize`(512 KiB)与 - `debug.bpf_bufsize_cap`(32 MiB)。**代码不依赖哪个对** —— `BIOCSBLEN` 超限时 - 内核**不报错**而是静默截断并把实际值写回参数,我们用写回值,并在被截断时打印 - 一条 INFO 说明。 -- 调大的收益有限:BPF 只有两份缓冲,增大单份并不能改善「copyout 期间 store 也满 - 了」的丢包窗口。真丢包了应该先看 `--stats` 输出里的「内核丢包」, - 再考虑是不是 RX 注入线程跟不上。 - -### 4. `--mtu`(默认 1500) - -- 上限受 `sysctl net.link.fake.max_mtu` 约束(本机 2048)。 -- ⚠️ 该 sysctl 是 feth 的**创建期快照**,改了它对已存在的接口无效。 -- ⚠️ BPF 写入还有一层硬限制:`hdrcmplt=1` 时整帧长度必须 ≤ 接口 MTU + 18 - (`BPF_WRITE_LEEWAY`)。 -- 设备汇报的 `MaxTransferSize` 或 `OID_GEN_MAXIMUM_FRAME_SIZE` 更小时会被自动下调。 - -### 5. 编译选项 +## Tuning knobs (ordered by payoff) + +### 1. `--max-transfer-kb` (default 16) -- **biggest payoff** + +The `MaxTransferSize` the host advertises in `INITIALIZE_MSG`. This is the +**only** way to get the **device** to aggregate multiple +`REMOTE_NDIS_PACKET_MSG`s into a single bulk IN. + +- The larger the advertised value -> the more the device aggregates -> the fewer + USB round trips and system calls amortized per frame. +- Linux advertises only 2048 (one full frame), very conservative. This project + defaults to 16 KiB. +- Cap: the spec requires USB 1.1 devices to stay at or below `0x4000` (16 KiB). +- **Cost**: the bulk IN buffer automatically grows with it (it must be >= the + advertised value); memory use = `rx-transfers x max-transfer-kb`. Default + 16 x 16 KiB = 256 KiB. + +**Warning:** but **the device may not cooperate**: the mainline Linux gadget +reports `MaxPacketsPerMessage = 1`, i.e. it sends one packet at a time, so +advertising a larger value has no effect. Only Android kernels with the Qualcomm +upstream aggregation patch report 3 or more. The startup log prints the +negotiated result; look for `device aggregation limit N packets` to see whether +it took effect. + +### 2. `--rx-transfers` (default 16) and `--rx-transfer-kb` (default 16) + +The number of concurrent in-flight bulk IN transfers and the size of each. The +goal is to **keep the USB controller queue from ever running dry**. + +- Total in flight = the product of the two; 256 KiB by default. +- Rationale: at 53 MB/s high speed, a 1 ms gap in the queue wastes 50 KB. Total + in flight must be >= 256 KiB to cover the scheduling jitter of "userspace + callback dispatch + resubmit". +- **Do not go "many and small"**: on macOS every `libusb_submit_transfer` costs + at least 3 IOKit round trips (the darwin backend calls + `darwin_get_pipe_properties` before every submission, which on + `IOUSBInterfaceInterface >= 550` equals two user-client calls). Conversely, + darwin imposes **no** cap on a single bulk length and does no splitting (16 KB + splitting is the Linux backend's business, not macOS's). + +### 3. `--bpf-buffer-kb` (default 4096) + +The BPF kernel capture buffer. The kernel allocates **two copies** (store + free +double buffering), so the actual wired memory is twice this value. + +- The cap is ambiguous: the two sources consulted point to + `debug.bpf_maxbufsize` (512 KiB) and `debug.bpf_bufsize_cap` (32 MiB) + respectively. **The code does not depend on which is right** -- when + `BIOCSBLEN` exceeds the cap the kernel **does not report an error**; it + silently clamps and writes the actual value back into the argument. We use the + written-back value and print an INFO line when it was clamped. +- The upside of raising it is limited: BPF has only two buffers, and enlarging + one does not shrink the loss window where "the store buffer also fills up + during copyout". If packets are actually being lost, first look at "kernel + drops" in the `--stats` output, then consider whether the RX injector thread + cannot keep up. + +### 4. `--mtu` (default 1500) + +- The ceiling is constrained by `sysctl net.link.fake.max_mtu` (2048 on this + machine). +- **Warning:** that sysctl is a **creation-time snapshot** for feth; changing it + has no effect on existing interfaces. +- **Warning:** BPF writes have one more hard limit: with `hdrcmplt=1` the whole + frame length must be <= the interface MTU + 18 (`BPF_WRITE_LEEWAY`). +- It is lowered automatically when the device reports a smaller + `MaxTransferSize` or `OID_GEN_MAXIMUM_FRAME_SIZE`. + +### 5. Compile options ```bash cmake -S . -B build-rel -DCMAKE_BUILD_TYPE=Release ``` -- **不要开 `-O3`**:热路径以 memcpy 与系统调用为主,`-O3` 的激进循环展开与向量化 - 几乎无收益,反而增大代码体积、恶化 I-cache 命中。 -- **`-DTETHERKIT_NATIVE_ARCH=ON` 收益很小**:arm64 baseline 已经生成 LSE 原子 - 指令(`ldadd`),`-mcpu=` 带来的只是调度模型差异,不是指令集解锁。 - 且注意 `-mcpu=apple-m5` **会被 Apple clang 21 拒绝**(`native` 解析为 `apple-m4`)。 -- **性能基准必须用未开消毒器的 Release 构建**,否则数字无参考价值。 +- **Do not enable `-O3`**: the hot path is dominated by memcpy and system calls; + `-O3`'s aggressive loop unrolling and vectorization gain almost nothing, and + instead increase code size and hurt I-cache hits. +- **`-DTETHERKIT_NATIVE_ARCH=ON` gains very little**: the arm64 baseline already + emits LSE atomic instructions (`ldadd`); `-mcpu=` only brings scheduling-model + differences, not an instruction-set unlock. Also note that + `-mcpu=apple-m5` **is rejected by Apple clang 21** (`native` resolves to + `apple-m4`). +- **Performance benchmarks must use a Release build with sanitizers disabled**; + otherwise the numbers are worthless. --- -## 怎么判断瓶颈在哪 +## How to tell where the bottleneck is -`--stats 1000` 会每秒打印一行: +`--stats 1000` prints one line per second: ``` -RX 3421 pps / 41.43 Mbps(丢 0) | TX 1180 pps / 14.29 Mbps(丢 0) | 队列深度 0 内核丢包 0 背压 0 +RX 3421 pps / 41.43 Mbps (dropped 0) | TX 1180 pps / 14.29 Mbps (dropped 0) | queue depth 0 kernel drops 0 backpressure 0 ``` -| 症状 | 含义 | 对策 | +| Symptom | Meaning | Countermeasure | |---|---|---| -| **队列深度**持续 > 0 且接近容量 | RX 注入线程(BPF write)跟不上 libusb 回调 | 确认 `批量写 已启用`(macOS 14+);调大 `bridge.rx_write_batch` | -| **RX 丢** 增长 | RX 队列满,同上 | 同上;或调大 `bridge.rx_ring_frames` | -| **内核丢包** 增长 | BPF 内核缓冲溢出,TX 抽取线程读得不够快 | 调大 `--bpf-buffer-kb`;检查 tx-extract 线程是否被别的负载挤占 | -| **背压** 增长 | TX 传输池占满,USB 侧写不出去 | 调大 `data_channel.tx_transfer_count`;或者设备本身就是瓶颈 | -| **TX 丢** 增长但背压为 0 | 帧超长或链路 down 期间被丢 | 看 MTU 协商结果与链路状态日志 | -| RX/TX 都远低于预期且无丢包 | 设备侧或对端就是瓶颈 | 看启动日志里的链路速率与设备聚合上限 | +| **queue depth** stays > 0 and near capacity | The RX injector thread (BPF write) cannot keep up with libusb callbacks | Confirm `batch write enabled` in the BPF-ready log (macOS 14+); raise `bridge.rx_write_batch` | +| **RX drops** keep growing | The RX queue is full; same as above | Same as above; or raise `bridge.rx_ring_frames` | +| **kernel drops** keep growing | The BPF kernel buffer overflowed; the TX extractor thread is not reading fast enough | Raise `--bpf-buffer-kb`; check whether the tx-extract thread is being starved by other load | +| **backpressure** keeps growing | The TX transfer pool is full; nothing can be written on the USB side | Raise `data_channel.tx_transfer_count`; or the device itself is the bottleneck | +| **TX drops** keep growing while backpressure is 0 | Oversized frames, or frames dropped while the link was down | Check the MTU negotiation result and the link state log | +| Both RX and TX far below expectation with no drops | The device side or the peer is the bottleneck | Check the link speed and the device aggregation limit in the startup log | -启动日志里这两行是判断「聚合有没有生效」的关键: +These two startup log lines are the key to telling whether aggregation is in +effect: ``` -数据通道就绪:RX 16 × 16 KiB(在飞 256 KiB),TX 4 × 16 KiB;设备聚合上限 1580 字节 / 1 包,对齐 1 字节 -数据路径已启动:RX 队列 2048 帧(4352 KiB),RX 写批 64 帧,TX 提交批 256 帧,链路批量写 可用 +data channel ready: RX 16 x 16 KiB (256 KiB in flight), TX 4 x 16 KiB; device aggregation limit 1580 bytes / 1 packets, alignment 1 bytes +data path started: RX queue 2048 frames (4352 KiB), RX write batch 64 frames, TX submit batch 256 frames, link batch write available ``` -- `设备聚合上限 … / 1 包` → 设备不支持聚合,`--max-transfer-kb` 调大也没用。 -- `链路批量写 不可用(逐帧写)` → 系统是 macOS 13 或更早,`BIOCSBATCHWRITE` - 不存在,RX 方向每帧一次系统调用,吞吐会明显低于 macOS 14+。 +- `device aggregation limit ... / 1 packets` -> the device does not support + aggregation; raising `--max-transfer-kb` is pointless. +- `link batch write unavailable (writing one frame at a time)` -> the system is + macOS 13 or earlier, `BIOCSBATCHWRITE` does not exist, the RX direction pays + one system call per frame, and throughput will be clearly lower than on + macOS 14+. --- -## 已知的性能限制 +## Known performance limitations -| 限制 | 影响 | 能否绕过 | +| Limitation | Impact | Workaround? | |---|---|---| -| macOS BPF **没有零拷贝**(无 `BIOCSETZBUF`) | 每方向一次内核↔用户拷贝 | 否。FreeBSD 有,Darwin 没有 | -| macOS **没有** CPU 亲和性 API | 数据路径线程可能被调度到效率核 | 否,只能用 QoS 表达意图(`thread_affinity_policy` 在 Apple Silicon 上基本无效) | -| `libusb_dev_mem_alloc` 在 darwin 返回 `NULL` | 没有零拷贝 DMA 缓冲 | 否。已改用 `posix_memalign` 按 `hw.pagesize`(16384) 对齐,减少 IOKit 建 DMA 描述符的分段 | -| Apple Silicon 上 libusb **控制传输**慢约 10 倍 | 保活与 OID 查询是毫秒级 | 否。因此保活周期不要调小,控制操作绝不放进数据热路径 | -| `BIOCSBATCHWRITE` 仅 macOS 14+ | macOS 13 及更早 RX 每帧一次系统调用 | 否,已自动回落 | -| feth 数据路径在 **> 5~8 Gbps** 时有内核 mbuf 溢出 panic 的社区报告 | 对本项目风险很低 | RNDIS over USB 2.0 实测 RX 325 / TX 235 Mbps,USB 3 也难超 1~2 Gbps,远低于该阈值 | -| ~~TX 高负载下背压丢帧~~ | ~~实测 6 秒内丢 1889 帧~~ | **已修复**:桥接层改为等待空闲传输槽位而不是丢弃,TX 丢帧降到 0。见 [BENCHMARKS.md](BENCHMARKS.md)「TX 丢帧的根因与修复」 | -| 双向并发时 RTT 会从 0.4 ms 膨胀到 16.6 ms | 修 TX 丢帧后已不再造成吞吐塌陷(双向 TX 4.8 → 87 Mbps),但延迟敏感场景仍受影响 | 部分能。缩小 RX 排队深度可缓解,但会牺牲 RX 峰值吞吐;改善幅度未实测 | -| TCP TX 呈双峰(约 240 / 约 300 Mbps 两个稳定态) | 吞吐不稳定,但两态都是零重传,不是丢包 | 原因未查明 | +| macOS BPF has **no zero-copy** (no `BIOCSETZBUF`) | One kernel<->userspace copy per direction | No. FreeBSD has it, Darwin does not | +| macOS has **no** CPU affinity API | Data-path threads may be scheduled onto efficiency cores | No; QoS is the only way to express intent (`thread_affinity_policy` is essentially ineffective on Apple Silicon) | +| `libusb_dev_mem_alloc` returns `NULL` on darwin | No zero-copy DMA buffer | No. Switched to `posix_memalign` aligned to `hw.pagesize` (16384) to reduce the segmentation when IOKit builds DMA descriptors | +| libusb **control transfers** are about 10x slower on Apple Silicon | Keep-alives and OID queries are millisecond-scale | No. So do not shorten the keep-alive period, and never put control operations on the data hot path | +| `BIOCSBATCHWRITE` is macOS 14+ only | On macOS 13 and earlier, RX pays one system call per frame | No; it already falls back automatically | +| Community reports of kernel mbuf overflow panics in the feth data path at **> 5~8 Gbps** | Very low risk for this project | Measured RNDIS over USB 2.0: RX 325 / TX 235 Mbps; even USB 3 will hardly exceed 1~2 Gbps, far below that threshold | +| ~~Backpressure dropping TX frames under high load~~ | ~~Measured: 1889 frames dropped in 6 seconds~~ | **Fixed**: the bridge layer now waits for a free transfer slot instead of dropping, and TX frame drops went to 0. See the "Root cause and fix for TX frame drops" section of [BENCHMARKS.md](BENCHMARKS.md) | +| Under bidirectional concurrency RTT inflates from 0.4 ms to 16.6 ms | After the TX frame-drop fix it no longer causes a throughput collapse (bidirectional TX 4.8 -> 87 Mbps), but latency-sensitive workloads are still affected | Partially. Shrinking the RX queue depth helps, at the cost of peak RX throughput; the amount of improvement has not been measured | +| TCP TX is bimodal (two stable states around 240 / around 300 Mbps) | Throughput is unstable, but both states have zero retransmits, so it is not packet loss | Cause not yet identified | --- -## 实测结论(2026-07-28 补齐) +## Measured results (completed 2026-07-28) -本节此前列的四项「未经实测」现在**全部有实测值了**。完整数据与方法见 -[BENCHMARKS.md](BENCHMARKS.md),这里只记结论: +The four "not yet measured" items previously listed in this section now **all +have measured values**. Full data and methodology are in +[BENCHMARKS.md](BENCHMARKS.md); only the conclusions are recorded here: -| 原先的估值 | 实测值 | 差异 | +| Previous estimate | Measured | Difference | |---|---|---| -| BPF `write()` ~700 ns(来自调研的 `write(/dev/null)`) | **2181 ns** | **贵约 3 倍** | -| `BIOCSBATCHWRITE` 提速倍数未知 | 805 ns/帧(batch ≥ 32) | **约 2.7 倍** | -| 端到端吞吐能否接近 426 Mbps | RX **324~327 Mbps**(理论上限的 77%) | 接近但达不到 | -| feth 往返延迟未知 | **24.5 µs** | 只占端到端 RTT 的约 5% | - -三条要点: - -1. **BPF 写入不是瓶颈。** 虽然实测比估值贵 3 倍,但批量写下仍有 124 万帧/秒 - (约 15 Gbps @1514 字节),而 USB 2.0 满速只需约 2.7 万帧/秒 —— 余量 45 倍。 - 本文档早先「BPF 写入决定 RX 方向理论上限」的担心可以放下。 -2. **这笔开销几乎与帧长无关**(64 字节 1934 ns vs 1514 字节 2181 ns,只差 13%), - 全是每次调用的固定成本 —— 所以批量写才是关键,而不是缩小帧。 -3. **TX 方向曾是弱侧,根因已查明并修复。** 现象是丢帧发生在本项目自己的 TX 路径 - 上;根因是桥接层在 bulk OUT 传输池占满时**立即丢弃**批次剩余帧,而槽位其实 - 只需等几百微秒就会释放。改为等待后:TX 丢帧 1889 → **0**,TCP TX 重传 - 3829 → **0**,双向并发 TX **4.8 → 87 Mbps**。 - 顺带否掉了原先的调优建议 —— 修复后 `--tx-transfers` 取 4 / 8 / 16 已无可分辨 - 差异,默认值保持 4。详见 BENCHMARKS.md「TX 丢帧的根因与修复」。 - -**教训**:那段丢弃逻辑当时附了一条很有说服力的注释(「等待会让内核缓冲堆积成 -看不见的丢包」),而两个前提都是错的 —— `bs_drop` 每批都读回来并显示在统计行里, -且 4 MiB 内核缓冲正是该吸收突发的那一级。**写下「刻意这样做」的理由时, -要能说出它的量级**:这里池深 4 个槽 vs 一个分片需要约 26 个槽,量级一对就露馅了。 - -仍然没查清的:TCP TX 的双峰行为、以及双向并发下的 RTT 膨胀。 -清单见 [BENCHMARKS.md](BENCHMARKS.md) 末节。 +| BPF `write()` ~700 ns (from research based on `write(/dev/null)`) | **2181 ns** | **about 3x as expensive** | +| `BIOCSBATCHWRITE` speedup factor unknown | 805 ns/frame (batch >= 32) | **about 2.7x** | +| Whether end-to-end throughput could approach 426 Mbps | RX **324~327 Mbps** (77% of the theoretical ceiling) | Close, but not reached | +| feth round-trip latency unknown | **24.5 us** | Only about 5% of the end-to-end RTT | + +Three takeaways: + +1. **BPF writes are not the bottleneck.** Although the measurement is 3x the + estimate, batch writes still sustain 1.24 million frames/s (about 15 Gbps at + 1514 bytes), while USB 2.0 at full speed needs only about 27,000 frames/s -- + a 45x margin. This document's earlier worry that "BPF writes determine the + theoretical RX-side ceiling" can be put to rest. +2. **The cost is almost independent of frame length** (1934 ns at 64 bytes vs + 2181 ns at 1514 bytes, only 13% apart); it is all fixed per-call cost -- + which is why batch writes are what matter, not shrinking frames. +3. **The TX direction used to be the weak side; the root cause has been found + and fixed.** The symptom was that frame drops occurred on this project's own + TX path; the root cause was that the bridge layer **immediately dropped** the + rest of a batch when the bulk OUT transfer pool was full, when a slot would + actually free up after only a few hundred microseconds. After switching to + waiting: TX frame drops 1889 -> **0**, TCP TX retransmits 3829 -> **0**, + bidirectional concurrent TX **4.8 -> 87 Mbps**. This also invalidated the + earlier tuning advice -- after the fix, `--tx-transfers` at 4 / 8 / 16 makes + no distinguishable difference, so the default stays 4. Details are in the + "Root cause and fix for TX frame drops" section of BENCHMARKS.md. + +**Lesson**: that drop logic came with a very persuasive comment ("waiting would +let the kernel buffer pile up into invisible drops"), and both premises were +wrong -- `bs_drop` is read back on every batch and displayed in the stats line, +and the 4 MiB kernel buffer is exactly the level that should absorb bursts. +**When writing down a reason for "deliberately doing this", be able to state its +magnitude**: here the pool is 4 slots deep vs about 26 slots needed for one +slice -- once the magnitudes are lined up, the problem shows itself. + +Still unresolved: the bimodal TCP TX behavior, and the RTT inflation under +bidirectional concurrency. The list is in the final section of +[BENCHMARKS.md](BENCHMARKS.md). diff --git a/docs/RNDIS-PROTOCOL.md b/docs/RNDIS-PROTOCOL.md index 2712934..3888a56 100644 --- a/docs/RNDIS-PROTOCOL.md +++ b/docs/RNDIS-PROTOCOL.md @@ -1,98 +1,112 @@ -# RNDIS 协议参考 +# RNDIS protocol reference -本文是实现 RNDIS **主机侧**时需要的全部线格式细节。所有数值都与 +This document is the complete wire-format detail needed to implement the RNDIS +**host side**. All values are consistent with [`include/tetherkit/rndis/protocol.h`](../include/tetherkit/rndis/protocol.h) -一致(那里是代码中的唯一来源),并与 Linux 内核实现交叉验证过: -`drivers/net/usb/rndis_host.c`、`include/linux/usb/rndis_host.h`、 -`drivers/usb/gadget/function/rndis.c`。 +(the single source of truth in the code) and were cross-checked against the Linux +kernel implementation: `drivers/net/usb/rndis_host.c`, +`include/linux/usb/rndis_host.h`, `drivers/usb/gadget/function/rndis.c`. --- -## 0. 三条最容易搞错的规则 +## 0. The three rules that are easiest to get wrong -### ① 所有多字节字段都是**小端** +### (1) All multi-byte fields are **little-endian** -协议源自 Windows NDIS。禁止用 packed struct 直接映射线格式 —— 一是大端机上会 -静默出错,二是 RNDIS 消息在 USB 缓冲里的起始偏移只保证 4 字节对齐,而 -`PACKET_MSG` 之后紧跟的以太帧起始偏移由设备的 `DataOffset` 决定,可能是任意值。 -本项目全部走 `memcpy` + 显式字节序转换(clang 会优化成单条 `ldr`/`rev`, -实测 0.10 ns/字段,零成本)。 +The protocol originates from Windows NDIS. Never map the wire format directly +onto a packed struct -- first, it fails silently on big-endian machines, and +second, an RNDIS message's starting offset inside a USB buffer is only guaranteed +to be 4-byte aligned, while the Ethernet frame that immediately follows a +`PACKET_MSG` starts at whatever the device's `DataOffset` says, which can be any +value. This project always goes through `memcpy` + explicit byte-order +conversion (clang optimizes that into a single `ldr`/`rev`; measured 0.10 ns per +field, zero cost). -### ② 偏移字段的基准点是**消息起始 + 8**,不是消息起始 +### (2) The base point of offset fields is **message start + 8**, not message start -`PACKET_MSG` 的 `DataOffset` / `OOBDataOffset` / `PerPacketInfoOffset`, -以及 `QUERY` / `SET` / `QUERY_CMPLT` 的 `InformationBufferOffset`, -基准点都是「该组偏移字段所属区块的起始」,即消息起始 + 8 字节处: +The `DataOffset` / `OOBDataOffset` / `PerPacketInfoOffset` of `PACKET_MSG`, and +the `InformationBufferOffset` of `QUERY` / `SET` / `QUERY_CMPLT`, all use "the +start of the block the group of offset fields belongs to" as their base point, +i.e. 8 bytes after the message start: ``` -绝对偏移 = 8 + 字段值 +absolute offset = 8 + field value ``` -所以「以太帧紧跟 44 字节 `PACKET_MSG` 头」时,`DataOffset` 要填 **36**,不是 44。 -**这是实现 RNDIS 的第一号陷阱。** 代码里用 `static_assert` 钉死了: +So when "the Ethernet frame immediately follows the 44-byte `PACKET_MSG` +header", `DataOffset` must be **36**, not 44. **This is trap number one when +implementing RNDIS.** The code pins it down with a `static_assert`: ```cpp inline constexpr std::uint32_t kPacketInlineDataOffset = kPacketMsgHeaderBytes - kOffsetFieldBase; -static_assert(kPacketInlineDataOffset == 36, "DataOffset 基准点是消息起始 +8"); +static_assert(kPacketInlineDataOffset == 36, + "DataOffset base point is message start + 8; see header rule 2"); ``` -### ③ `INDICATE_STATUS` 的 `StatusBufferOffset` 是例外 +### (3) `INDICATE_STATUS`'s `StatusBufferOffset` is the exception -Microsoft 文档写它的基准点是**消息起始**(+0),与上面的 +8 约定**不一致**。 -这是规范自身的矛盾。实践中该字段绝大多数为 0(`MEDIA_CONNECT` / -`MEDIA_DISCONNECT` 不带 status buffer),因此本项目的解析器按以下顺序容错: +Microsoft's documentation says its base point is **the message start** (+0), +**inconsistent** with the +8 convention above. This is a contradiction within the +spec itself. In practice the field is almost always 0 (`MEDIA_CONNECT` / +`MEDIA_DISCONNECT` carry no status buffer), so this project's parser tolerates +it with the following fallback order: -1. 长度为 0 → 无负载,直接成功(绝大多数情况); -2. 先按「基准点 = 消息起始 + 8」解释,落在消息范围内则采用; -3. 否则按「基准点 = 消息起始」解释; -4. 两种都越界 → 丢弃状态缓冲区但**仍返回成功** —— `status` 本身有用 - (可能就是 `MEDIA_DISCONNECT`),不能因为一个可选负载解析不了就把链路判死。 +1. Length is 0 -> no payload, succeed immediately (the vast majority of cases); +2. First interpret it as "base point = message start + 8"; accept it if that + lands within the message; +3. Otherwise interpret it as "base point = message start"; +4. Out of range either way -> drop the status buffer but **still return success** + -- the `status` itself is useful (it may be `MEDIA_DISCONNECT`), and the link + must not be declared dead because one optional payload cannot be parsed. --- -## 1. 消息类型码 +## 1. Message type codes -| 消息 | 码值 | 备注 | +| Message | Code | Notes | |---|---|---| -| `REMOTE_NDIS_PACKET_MSG` | `0x00000001` | 数据通道 | +| `REMOTE_NDIS_PACKET_MSG` | `0x00000001` | Data path | | `REMOTE_NDIS_INITIALIZE_MSG` | `0x00000002` | | -| `REMOTE_NDIS_HALT_MSG` | `0x00000003` | **设备不回复** | +| `REMOTE_NDIS_HALT_MSG` | `0x00000003` | **The device does not reply** | | `REMOTE_NDIS_QUERY_MSG` | `0x00000004` | | | `REMOTE_NDIS_SET_MSG` | `0x00000005` | | -| `REMOTE_NDIS_RESET_MSG` | `0x00000006` | **无 RequestId** | -| `REMOTE_NDIS_INDICATE_STATUS_MSG` | `0x00000007` | 设备发起,**无 RequestId、无需回复** | -| `REMOTE_NDIS_KEEPALIVE_MSG` | `0x00000008` | **双向都可发起** | -| 完成消息 | 请求码 \| `0x80000000` | | -| `RNDIS_MSG_BUS` | `0xFF000001` | 私有,本项目仅识别并忽略 | +| `REMOTE_NDIS_RESET_MSG` | `0x00000006` | **No RequestId** | +| `REMOTE_NDIS_INDICATE_STATUS_MSG` | `0x00000007` | Device-initiated, **no RequestId, no reply needed** | +| `REMOTE_NDIS_KEEPALIVE_MSG` | `0x00000008` | **Either side may initiate** | +| Completion message | request code \| `0x80000000` | | +| `RNDIS_MSG_BUS` | `0xFF000001` | Private; this project only recognizes and ignores it | -面向连接(CONDIS)消息族 `0x00008001`~`0x00008007`:802.3 设备不会用到。 -本项目识别它们只为了能明确报「不支持面向连接设备」而不是含糊的「未知消息」。 +The connection-oriented (CONDIS) message family `0x00008001`~`0x00008007` is +never used by 802.3 devices. This project recognizes them only so it can report +"connection-oriented device not supported" explicitly instead of a vague "unknown +message". --- -## 2. 消息长度与字段偏移 +## 2. Message lengths and field offsets -所有字段都是 LE32。 +All fields are LE32. -| 消息 | 总长 | 字段偏移 | +| Message | Total length | Field offsets | |---|---:|---| | `INITIALIZE_MSG` | 24 | Type@0, Length@4, RequestId@8, MajorVersion@12, MinorVersion@16, MaxTransferSize@20 | -| `INITIALIZE_CMPLT` | 52 | …, Status@12, MajorVersion@16, MinorVersion@20, DeviceFlags@24, Medium@28, **MaxPacketsPerMessage@32**, **MaxTransferSize@36**, **PacketAlignmentFactor@40**, AFListOffset@44, AFListSize@48 | -| `HALT_MSG` | 12 | …, RequestId@8 | -| `QUERY_MSG` = `SET_MSG` | 28 + 负载 | …, RequestId@8, Oid@12, InfoBufferLength@16, InfoBufferOffset@20, DeviceVcHandle@24 | -| `QUERY_CMPLT` | 24 + 负载 | …, Status@12, InfoBufferLength@16, InfoBufferOffset@20 | -| `SET_CMPLT` | 16 | …, Status@12 | -| `RESET_MSG` | 12 | …, **Reserved@8**(⚠️ 不是 RequestId) | -| `RESET_CMPLT` | 16 | …, **Status@8**(⚠️ 不是 12), **AddressingReset@12** | -| `INDICATE_STATUS_MSG` | 20 + 负载 | …, Status@8, StatusBufferLength@12, StatusBufferOffset@16 | -| `KEEPALIVE_MSG` | 12 | …, RequestId@8 | -| `KEEPALIVE_CMPLT` | 16 | …, RequestId@8, Status@12 | -| `PACKET_MSG` 头 | 44 | Type@0, Length@4, **DataOffset@8**, DataLength@12, OOBDataOffset@16, OOBDataLength@20, NumOOBDataElements@24, PerPacketInfoOffset@28, PerPacketInfoLength@32, VcHandle@36, Reserved@40 | - -「数据紧跟头部」时偏移字段该填的值: - -| 消息 | 头长 | 偏移字段值 | +| `INITIALIZE_CMPLT` | 52 | Type@0, Length@4, RequestId@8, Status@12, MajorVersion@16, MinorVersion@20, DeviceFlags@24, Medium@28, **MaxPacketsPerMessage@32**, **MaxTransferSize@36**, **PacketAlignmentFactor@40**, AFListOffset@44, AFListSize@48 | +| `HALT_MSG` | 12 | Type@0, Length@4, RequestId@8 | +| `QUERY_MSG` = `SET_MSG` | 28 + payload | Type@0, Length@4, RequestId@8, Oid@12, InfoBufferLength@16, InfoBufferOffset@20, DeviceVcHandle@24 | +| `QUERY_CMPLT` | 24 + payload | Type@0, Length@4, RequestId@8, Status@12, InfoBufferLength@16, InfoBufferOffset@20 | +| `SET_CMPLT` | 16 | Type@0, Length@4, RequestId@8, Status@12 | +| `RESET_MSG` | 12 | Type@0, Length@4, **Reserved@8** (WARNING: not RequestId) | +| `RESET_CMPLT` | 16 | Type@0, Length@4, **Status@8** (WARNING: not 12), **AddressingReset@12** | +| `INDICATE_STATUS_MSG` | 20 + payload | Type@0, Length@4, Status@8, StatusBufferLength@12, StatusBufferOffset@16 | +| `KEEPALIVE_MSG` | 12 | Type@0, Length@4, RequestId@8 | +| `KEEPALIVE_CMPLT` | 16 | Type@0, Length@4, RequestId@8, Status@12 | +| `PACKET_MSG` header | 44 | Type@0, Length@4, **DataOffset@8**, DataLength@12, OOBDataOffset@16, OOBDataLength@20, NumOOBDataElements@24, PerPacketInfoOffset@28, PerPacketInfoLength@32, VcHandle@36, Reserved@40 | + +The values the offset fields should hold when "data immediately follows the +header": + +| Message | Header length | Offset field value | |---|---:|---:| | `PACKET_MSG` | 44 | **36** | | `QUERY_MSG` / `SET_MSG` | 28 | **20** | @@ -100,70 +114,75 @@ Microsoft 文档写它的基准点是**消息起始**(+0),与上面的 +8 --- -## 3. 状态码 +## 3. Status codes -关键的几个(完整列表见 `protocol.h` 的 `StatusCode`): +The key ones (for the full list see `StatusCode` in `protocol.h`): -| 名字 | 值 | 说明 | +| Name | Value | Meaning | |---|---|---| | `SUCCESS` | `0x00000000` | | | `PENDING` | `0x00000103` | | -| `MEDIA_CONNECT` | `0x4001000B` | 链路 up | -| `MEDIA_DISCONNECT` | `0x4001000C` | 链路 down | +| `MEDIA_CONNECT` | `0x4001000B` | Link up | +| `MEDIA_DISCONNECT` | `0x4001000C` | Link down | | `LINK_SPEED_CHANGE` | `0x40010013` | | | `FAILURE` | `0xC0000001` | | -| `NOT_SUPPORTED` | `0xC00000BB` | 可选 OID 的正常回应 | -| `INVALID_LENGTH` | `0xC0010014` | ⚠️ 易记错 | -| `INVALID_DATA` | `0xC0010015` | ⚠️ 易记错 | +| `NOT_SUPPORTED` | `0xC00000BB` | Normal reply for an optional OID | +| `INVALID_LENGTH` | `0xC0010014` | Easy to misremember | +| `INVALID_DATA` | `0xC0010015` | Easy to misremember | | `BUFFER_TOO_SHORT` | `0xC0010016` | | -| `INVALID_OID` | `0xC0010017` | ⚠️ 易记错 | +| `INVALID_OID` | `0xC0010017` | Easy to misremember | -> 这四个带 ⚠️ 的值本项目最初在 `error.cc` 里抄错过(写成了 `0xC000000D` / -> `0xC0010015` / `0xC0000010` / `0x80000005`)。现在状态码表只存在于 -> `rndis/protocol.cc` 一处,避免两处不一致。 +> These four warning-flagged values were once transcribed wrong in `error.cc` +> (written as `0xC000000D` / `0xC0010015` / `0xC0000010` / `0x80000005`). Today +> the status code table exists in exactly one place, `rndis/protocol.cc`, to +> avoid inconsistency between two copies. -失败判定:`(status & 0xC0000000) == 0xC0000000`。 +Failure test: `(status & 0xC0000000) == 0xC0000000`. --- ## 4. OID -| OID | 值 | 数据格式 | 本项目用途 | +| OID | Value | Data format | Use in this project | |---|---|---|---| -| `OID_GEN_SUPPORTED_LIST` | `0x00010101` | **变长**:N 个 LE32 | 未使用 | -| `OID_GEN_MAXIMUM_FRAME_SIZE` | `0x00010106` | LE32,**不含**以太头 | 校验并可能下调 MTU | -| `OID_GEN_LINK_SPEED` | `0x00010107` | LE32,**单位 100 bps** | 日志 | -| `OID_GEN_VENDOR_ID` | `0x0001010C` | LE32,低 24 位是 OUI | 日志 | -| `OID_GEN_VENDOR_DESCRIPTION` | `0x0001010D` | **变长** ASCII,**不保证 NUL 结尾** | 日志 | -| `OID_GEN_CURRENT_PACKET_FILTER` | `0x0001010E` | LE32 位掩码,可读可写 | **设非零值才让数据流动** | -| `OID_GEN_MEDIA_CONNECT_STATUS` | `0x00010114` | LE32:**0=已连接**,1=断开 | 初始链路状态 | -| `OID_GEN_PHYSICAL_MEDIUM` | `0x00010202` | LE32,**可选 OID** | 日志(失败不致命) | -| `OID_802_3_PERMANENT_ADDRESS` | `0x01010101` | 6 字节 MAC | **主机侧 feth 采用的地址** | -| `OID_802_3_CURRENT_ADDRESS` | `0x01010102` | 6 字节 MAC | 日志 | -| `OID_802_3_MULTICAST_LIST` | `0x01010103` | SET:6N 字节 | 未使用(包过滤里开了 ALL_MULTICAST) | - -⚠️ **`OID_GEN_MEDIA_CONNECT_STATUS` 的 0 表示「已连接」**,不是断开。 - -⚠️ **不要依赖 `QUERY OID_802_3_MULTICAST_LIST`**:Linux gadget 对它返回一个 -4 字节的 `0xE0000000`,不符合「6 字节 MAC 数组」的格式。只用 SET 方向。 - -### `InformationBufferLength` 的反直觉规则 - -这条来自嗅探微软 ActiveSync 4.1 的 Windows 驱动,**未文档化**: - -| OID 类型 | `InformationBufferLength` | 违反后果 | +| `OID_GEN_SUPPORTED_LIST` | `0x00010101` | **Variable-length**: N LE32s | Unused | +| `OID_GEN_MAXIMUM_FRAME_SIZE` | `0x00010106` | LE32, **excludes** the Ethernet header | Validated; may lower the MTU | +| `OID_GEN_LINK_SPEED` | `0x00010107` | LE32, **unit 100 bps** | Log | +| `OID_GEN_VENDOR_ID` | `0x0001010C` | LE32; the low 24 bits are the OUI | Log | +| `OID_GEN_VENDOR_DESCRIPTION` | `0x0001010D` | **Variable-length** ASCII, **not guaranteed NUL-terminated** | Log | +| `OID_GEN_CURRENT_PACKET_FILTER` | `0x0001010E` | LE32 bitmask, readable and writable | **Data only flows after a non-zero value is set** | +| `OID_GEN_MEDIA_CONNECT_STATUS` | `0x00010114` | LE32: **0 = connected**, 1 = disconnected | Initial link state | +| `OID_GEN_PHYSICAL_MEDIUM` | `0x00010202` | LE32, **optional OID** | Log (failure is not fatal) | +| `OID_802_3_PERMANENT_ADDRESS` | `0x01010101` | 6-byte MAC | **The address the host-side feth adopts** | +| `OID_802_3_CURRENT_ADDRESS` | `0x01010102` | 6-byte MAC | Log | +| `OID_802_3_MULTICAST_LIST` | `0x01010103` | SET: 6N bytes | Unused (ALL_MULTICAST is enabled in the packet filter) | + +**Warning:** for `OID_GEN_MEDIA_CONNECT_STATUS`, **0 means "connected"**, not +disconnected. + +**Warning:** **do not rely on `QUERY OID_802_3_MULTICAST_LIST`**: the Linux +gadget returns a 4-byte `0xE0000000` for it, which does not match the "6-byte +MAC array" format. Use only the SET direction. + +### The counter-intuitive rule for `InformationBufferLength` + +This comes from sniffing the Windows driver for Microsoft ActiveSync 4.1; it is +**undocumented**: + +| OID type | `InformationBufferLength` | Consequence of violating it | |---|---|---| -| 返回**定长**结果 | 必须 ≥ 期望响应长度,且消息尾部要真的有那么多字节 | `RNDIS_STATUS_INVALID_LENGTH` | -| 返回**变长**结果 | 必须为 **0** | 同样出错 | +| Returns a **fixed-length** result | Must be >= the expected response length, and the message tail must actually contain that many bytes | `RNDIS_STATUS_INVALID_LENGTH` | +| Returns a **variable-length** result | Must be **0** | Fails the same way | -本项目的 `Encode(QueryRequest)` 按 `IsVariableLengthOid()` 自动处理, -调用方不必操心。测试里有专门的用例把这条规则钉住。 +This project's `Encode(QueryRequest)` handles it automatically based on +`IsVariableLengthOid()`; callers do not need to worry. A dedicated test case +pins this rule down. --- -## 5. 包过滤位掩码 +## 5. Packet filter bitmask -| 名字 | 值 | +| Name | Value | |---|---| | `DIRECTED` | `0x00000001` | | `MULTICAST` | `0x00000002` | @@ -171,194 +190,231 @@ Microsoft 文档写它的基准点是**消息起始**(+0),与上面的 +8 | `BROADCAST` | `0x00000008` | | `PROMISCUOUS` | `0x00000020` | -本项目用 `DIRECTED | BROADCAST | ALL_MULTICAST | PROMISCUOUS` = **`0x0000002D`** -(与 Linux `rndis_host` 一致)。 +This project uses `DIRECTED | BROADCAST | ALL_MULTICAST | PROMISCUOUS` = +**`0x0000002D`** (the same as Linux `rndis_host`). -开 `PROMISCUOUS` 的理由:我们把设备当成一根「网线」桥接到 feth,主机侧可能收发 -任意 MAC 的帧(上层再做桥接或跑虚拟机时)。开 `ALL_MULTICAST` 的理由:避免维护 -组播表还漏掉 IPv6 邻居发现。 +Reason for `PROMISCUOUS`: we treat the device as an "Ethernet cable" bridged +into feth, so the host side may send and receive frames with any MAC (when +bridging further upstream or running virtual machines). Reason for +`ALL_MULTICAST`: it avoids maintaining a multicast table while still not +missing IPv6 neighbor discovery. --- -## 6. 主机侧状态机 +## 6. Host-side state machine -规范定义的是**设备侧**三态,主机侧镜像它并补上过渡态: +The spec defines three **device-side** states; the host side mirrors them and +adds transitional states: ``` -kUninitialized ──Start()/INITIALIZE──► kInitializing ──CMPLT+协商通过──► kInitialized - ▲ │ │ - │ 协商失败 SET filter≠0 - │ │ ▼ - └──────────── HALT / 断开 ────────────┴──────────────────── kDataInitialized - │ - SET filter=0 │ Stop() - (退回上态) ▼ - kHalting +kUninitialized --Start()/INITIALIZE--> kInitializing --CMPLT + negotiation passed--> kInitialized + ^ | | + | negotiation failed SET filter != 0 + | | v + +-------------- HALT / disconnect ----+-------------------- kDataInitialized + | + SET filter=0 | Stop() + (fall back one state) v + kHalting ``` -规范原文语义: +Semantics from the spec itself: -- 总线初始化后设备处于 RNDIS-uninitialized; -- 收到 `INITIALIZE_MSG` 并回 `INITIALIZE_CMPLT(SUCCESS)` → RNDIS-initialized; -- 收到 `SET(OID_GEN_CURRENT_PACKET_FILTER, **非零**)` → RNDIS-data-initialized, - **数据这才开始流动**; -- data-initialized 下收到 `SET(filter = 0)` → 退回 RNDIS-initialized; -- 任意时刻收到 `HALT_MSG` 或总线断开 → 立即回 RNDIS-uninitialized。 +- After bus initialization the device is in RNDIS-uninitialized; +- Receiving `INITIALIZE_MSG` and replying with `INITIALIZE_CMPLT(SUCCESS)` -> + RNDIS-initialized; +- Receiving `SET(OID_GEN_CURRENT_PACKET_FILTER, **non-zero**)` -> + RNDIS-data-initialized, **and only now does data start flowing**; +- While in data-initialized, receiving `SET(filter = 0)` -> falls back to + RNDIS-initialized; +- Receiving `HALT_MSG` or a bus disconnect at any time -> immediately back to + RNDIS-uninitialized. -### 启动序列(照 Linux 的 `generic_rndis_bind`) +### Startup sequence (following Linux's `generic_rndis_bind`) -| 步 | 操作 | 致命? | +| Step | Operation | Fatal? | |---|---|---| -| 1 | `INITIALIZE_MSG` → `INITIALIZE_CMPLT`,协商参数 | ✔ | -| 2 | `QUERY OID_GEN_PHYSICAL_MEDIUM`(`in_len=4`) | ✘ 可选 OID | -| 3 | `QUERY OID_802_3_PERMANENT_ADDRESS`(`in_len=48`) | ✔ | -| 4 | `QUERY` 当前 MAC / 最大帧长 / 链路速率 / 连接状态 / 厂商信息 | ✘ | -| 5 | `SET OID_GEN_CURRENT_PACKET_FILTER = 0x2D` | ✔ | - -任一致命步骤失败都会发 `HALT_MSG` 再返回错误,且状态回到未初始化而不是卡在中间态。 - -### 三条容易写错的交互细节 - -**① 控制请求必须串行化。** -`GET_ENCAPSULATED_RESPONSE` 只能「取下一条排队的响应」,没有按 `RequestId` -选取的能力。所以同一时刻只能有一个请求在飞。 - -**② 设备会插队。** -在等某个 `*_CMPLT` 的过程中,设备完全可能先塞进来一条 `INDICATE_STATUS_MSG` -(媒体连接状态变化),或一条**设备发起的** `KEEPALIVE_MSG` —— 此时主机 -**必须回** `KEEPALIVE_CMPLT`,否则设备可能认为主机已死而断开。 -因此控制读循环必须写成 dispatcher(按 `MessageType` 分派),而不是 -「盲目假定读到的就是我要的那条」。 - -**③ `RESET` 的语义。** -`RESET` 是 soft reset:控制通道保持完整,但设备丢弃**所有**未完成的请求与数据包。 -`RESET_CMPLT` 的 `AddressingReset` 非零表示寻址信息(包过滤、组播表)在复位中 -丢失,主机**必须重发** `SET(OID_GEN_CURRENT_PACKET_FILTER)` —— 否则复位后数据 -不会再流动。Linux gadget 无条件把 `AddressingReset` 置 1。 +| 1 | `INITIALIZE_MSG` -> `INITIALIZE_CMPLT`, negotiate parameters | Yes | +| 2 | `QUERY OID_GEN_PHYSICAL_MEDIUM` (`in_len=4`) | No (optional OID) | +| 3 | `QUERY OID_802_3_PERMANENT_ADDRESS` (`in_len=48`) | Yes | +| 4 | `QUERY` current MAC / max frame size / link speed / connection status / vendor info | No | +| 5 | `SET OID_GEN_CURRENT_PACKET_FILTER = 0x2D` | Yes | + +If any fatal step fails, `HALT_MSG` is sent before returning the error, and the +state goes back to uninitialized instead of getting stuck in an intermediate +state. + +### Three interaction details that are easy to get wrong + +**(1) Control requests must be serialized.** +`GET_ENCAPSULATED_RESPONSE` can only "take the next queued response"; it has no +ability to select by `RequestId`. So only one request may be in flight at a +time. + +**(2) The device can jump the queue.** +While waiting for some `*_CMPLT`, the device may well insert an +`INDICATE_STATUS_MSG` (media connection state change) first, or a +**device-initiated** `KEEPALIVE_MSG` -- in that case the host **must reply** +with `KEEPALIVE_CMPLT`, or the device may decide the host is dead and +disconnect. Therefore the control read loop must be written as a dispatcher +(dispatching on `MessageType`), not "blindly assuming whatever was just read is +the message we wanted". + +**(3) The semantics of `RESET`.** +`RESET` is a soft reset: the control channel stays intact, but the device +discards **all** outstanding requests and packets. A non-zero `AddressingReset` +in `RESET_CMPLT` means addressing information (packet filter, multicast table) +was lost in the reset, and the host **must resend** +`SET(OID_GEN_CURRENT_PACKET_FILTER)` -- otherwise data will not flow again after +the reset. The Linux gadget sets `AddressingReset` to 1 unconditionally. --- -## 7. USB 层 +## 7. USB layer -### 控制通道 +### Control channel -| 请求 | `bmRequestType` | `bRequest` | `wValue` | `wIndex` | +| Request | `bmRequestType` | `bRequest` | `wValue` | `wIndex` | |---|---|---|---|---| -| `SEND_ENCAPSULATED_COMMAND` | `0x21`(OUT\|Class\|Interface) | `0x00` | 0 | 通信类接口号 | -| `GET_ENCAPSULATED_RESPONSE` | `0xA1`(IN\|Class\|Interface) | `0x01` | 0 | 通信类接口号 | +| `SEND_ENCAPSULATED_COMMAND` | `0x21` (OUT\|Class\|Interface) | `0x00` | 0 | Communication class interface number | +| `GET_ENCAPSULATED_RESPONSE` | `0xA1` (IN\|Class\|Interface) | `0x01` | 0 | Communication class interface number | -⚠️ **设备尚无有效响应时,规范要求它返回 1 字节 `0x00`,而不是 STALL 控制端点。** -因此主机收到 < 8 字节(一个 RNDIS 消息头)的结果必须当作「还没准备好,稍后重试」, -**不能当作错误**。 +**Warning:** **when the device has no valid response yet, the spec requires it +to return 1 byte `0x00` rather than STALL the control endpoint.** Therefore a +result of < 8 bytes (one RNDIS message header) must be treated as "not ready +yet, retry later", **not as an error**. -### 中断 IN 通知 +### Interrupt IN notification -8 字节 = 两个 LE32:`Notification`@0(`0x00000001` = RESPONSE_AVAILABLE)、 -`Reserved`@4。 +8 bytes = two LE32s: `Notification`@0 (`0x00000001` = RESPONSE_AVAILABLE), +`Reserved`@4. -⚠️ 这**不是** CDC 的 `usb_cdc_notification` 结构,而是 RNDIS 自有格式。 +**Warning:** this is **not** CDC's `usb_cdc_notification` struct but RNDIS's own +format. -设备行为有三类,实现必须都兼容: +Device behavior comes in three flavors; an implementation must be compatible +with all of them: -1. 规范做法:主机等中断 IN 上的通知; -2. Linux 做法:**完全忽略**中断端点,直接对 `GET_ENCAPSULATED_RESPONSE` - 轮询最多 10 次、每次间隔 40 ms; -3. 反过来还存在**必须先被中断端点读过一次才会在控制端点上作答**的设备。 +1. The spec's way: the host waits for a notification on the interrupt IN; +2. Linux's way: **completely ignore** the interrupt endpoint and poll + `GET_ENCAPSULATED_RESPONSE` directly, at most 10 times, 40 ms apart; +3. Conversely, there are also devices that **will only answer on the control + endpoint after the interrupt endpoint has been read once**. -本项目的策略:有中断端点就先等一次通知(超时也不算错),然后**无论如何**都去轮询。 +This project's policy: if an interrupt endpoint is present, wait for one +notification first (a timeout is not an error either), then poll **no matter +what**. -### 接口签名(四种已知形态) +### Interface signatures (four known forms) -| 形态 | 通信类接口 | 数据类接口 | +| Form | Communication interface | Data interface | |---|---|---| -| 标准 MS RNDIS | `0x02 / 0x02 / 0xFF` | `0x0A / 0x00 / 0x00` | -| ActiveSync(Windows Mobile / Phone) | `0xEF / 0x01 / 0x01` | 同上 | -| 无线 RNDIS(手机共享、WWAN 模块) | `0xE0 / 0x01 / 0x03` | 同上 | -| Novatel/Verizon USB730L 变体 | `0xEF / 0x04 / 0x01` | 同上 | +| Standard MS RNDIS | `0x02 / 0x02 / 0xFF` | `0x0A / 0x00 / 0x00` | +| ActiveSync (Windows Mobile / Phone) | `0xEF / 0x01 / 0x01` | Same as above | +| Wireless RNDIS (phone tethering, WWAN modules) | `0xE0 / 0x01 / 0x03` | Same as above | +| Novatel/Verizon USB730L variant | `0xEF / 0x04 / 0x01` | Same as above | -**排除伪 RNDIS**:class == `0x02` 且带**非零** CDC ACM `bmCapabilities` 的是真 -cdc-acm 调制解调器,不是 RNDIS。⚠️ **这条检查只能对 class `0x02` 生效** —— -无线类(`0xE0`)的 RNDIS function 会把 `bmCapabilities` 字段挪作自用。 +**Excluding pseudo-RNDIS**: class == `0x02` with a **non-zero** CDC ACM +`bmCapabilities` is a genuine cdc-acm modem, not RNDIS. **Warning:** **this +check may only be applied to class `0x02`** -- wireless-class (`0xE0`) RNDIS +functions repurpose the `bmCapabilities` field for their own use. --- -## 8. 多包聚合与 ZLP - -### 聚合规则 - -- **device → host**:每个 `PACKET_MSG` 应从「多包消息起始」的 8 字节整数倍偏移开始; -- **host → device**:必须遵守设备在 `INITIALIZE_CMPLT` 里给的 - `PacketAlignmentFactor`,对齐字节 = `1 << factor`,**合法上界 7**(=128 字节); -- **`MessageLength` 包含填充** —— 除最后一个之外的每个 `PACKET_MSG` 的 - `MessageLength` 都含尾部对齐填充,最后一个**不含**外部填充; -- 单次传输总字节数 ≤ 对端的 `MaxTransferSize`,包数 ≤ `MaxPacketsPerMessage`; -- 遍历方式:`offset += MessageLength`。 - -本项目的编码器实现「填充归入**上一个**消息」的做法:在追加下一个消息时才回头 -扩大上一个消息的 `MessageLength` 以吞掉中间的填充 —— 这样最后一个消息的 -`MessageLength` 天然就是 `44 + 帧长`,完全符合规范。 - -### ZLP 规避 - -RNDIS 明确要求主机**不得**发零长度包(`rndis_host.c` 顶部注释原文就是 -"DATA -- host must not write zlps")。而当传输长度恰为端点 `wMaxPacketSize` 的 -整数倍时,USB 主机控制器需要一个短包来标记传输结束。 - -Linux `usbnet` 的做法(本项目照做):**追加 1 个 `0x00` 字节**,让传输变成短包。 -这个字节落在所有 `MessageLength` 之外,设备必须容忍尾部垃圾。 - -⚠️ 反过来,**主机的 RX 解析器也必须容忍 `actual_length > MessageLength`**。 -不容忍的话,每个满传输都会误报一次畸形。本项目的解析器把「剩余不足一个完整 -44 字节头部」的尾部一律当填充忽略。 - -⚠️ `LIBUSB_TRANSFER_ADD_ZERO_PACKET` 在 macOS 上**不可用**(头文件明确写非 Linux -系统会返回 `LIBUSB_ERROR_NOT_SUPPORTED`)。本项目不依赖它。 +## 8. Multi-packet aggregation and ZLP + +### Aggregation rules + +- **device -> host**: every `PACKET_MSG` should start at an offset that is an + integer multiple of 8 bytes from "the start of the multi-packet message"; +- **host -> device**: must obey the `PacketAlignmentFactor` the device gives in + `INITIALIZE_CMPLT`; alignment bytes = `1 << factor`, **legal upper bound 7** + (=128 bytes); +- **`MessageLength` includes padding** -- every `PACKET_MSG` except the last has + the trailing alignment padding inside its `MessageLength`; the last one + **excludes** external padding; +- The total bytes of a single transfer <= the peer's `MaxTransferSize`, and the + packet count <= `MaxPacketsPerMessage`; +- Traversal: `offset += MessageLength`. + +This project's encoder assigns the padding to the **previous** message: only +when appending the next message does it go back and enlarge the previous +message's `MessageLength` to swallow the padding in between -- so the last +message's `MessageLength` is naturally `44 + frame length`, fully conforming to +the spec. + +### ZLP avoidance + +RNDIS explicitly requires that the host **must not** send zero-length packets +(the comment at the top of `rndis_host.c` reads exactly "DATA -- host must not +write zlps"). But when the transfer length happens to be an exact multiple of +the endpoint `wMaxPacketSize`, the USB host controller needs a short packet to +mark the end of the transfer. + +The Linux `usbnet` approach (which this project follows): **append one `0x00` +byte** so the transfer becomes a short packet. This byte falls outside every +`MessageLength`; the device must tolerate trailing garbage. + +**Warning:** conversely, **the host's RX parser must also tolerate +`actual_length > MessageLength`**. Without that tolerance, every full transfer +would be falsely reported as malformed once. This project's parser treats any +tail with less than one complete 44-byte header remaining as padding and +ignores it. + +**Warning:** `LIBUSB_TRANSFER_ADD_ZERO_PACKET` is **not available** on macOS +(the header explicitly states that non-Linux systems return +`LIBUSB_ERROR_NOT_SUPPORTED`). This project does not rely on it. --- -## 9. `MaxTransferSize` —— 吞吐的主要杠杆 +## 9. `MaxTransferSize` -- the main lever for throughput -主机在 `INITIALIZE_MSG` 里宣称的 `MaxTransferSize` 是**让设备把多个 -`PACKET_MSG` 聚合进一次 bulk IN 的唯一手段**。报得越大、设备聚合越多、每帧摊到 -的 USB 与系统调用开销越低。 +The `MaxTransferSize` the host advertises in `INITIALIZE_MSG` is **the only way +to get the device to aggregate multiple `PACKET_MSG`s into one bulk IN**. The +larger the advertised value, the more the device aggregates, and the lower the +per-frame USB and system call overhead. -Linux 的算法很保守(只报一个满帧): +Linux's algorithm is conservative (it advertises only one full frame): ``` hard_header_len = ETH_HLEN(14) + sizeof(rndis_data_hdr)(44) = 58 -hard_mtu = MTU + 58 # MTU=1500 → 1558 +hard_mtu = MTU + 58 # MTU=1500 -> 1558 rx_urb_size = (hard_mtu + maxpacket + 1) & ~(maxpacket - 1) - # 高速(512) → 2048;全速(64) → 1600 + # high-speed(512) -> 2048; full-speed(64) -> 1600 ``` -本项目默认报 **16 KiB**(可用 `--max-transfer-kb` 调),既留足聚合空间, -又不超过规范对 USB 1.1 设备的 `0x4000` 上限。bulk IN 缓冲会自动跟着放大 —— -它必须 ≥ 我们宣称的值,否则设备聚合出来的大传输会溢出/被截断。 +This project advertises **16 KiB** by default (tunable with +`--max-transfer-kb`), which leaves plenty of aggregation room while staying +within the spec's `0x4000` cap for USB 1.1 devices. The bulk IN buffer grows +along with it automatically -- it must be >= the value we advertise, or the +large transfers the device aggregates will overflow / be truncated. --- -## 10. 已知设备 quirk +## 10. Known device quirks -| quirk | 现象 | 本项目的处理 | +| quirk | Symptom | How this project handles it | |---|---|---| -| **Android CDC Union 损坏** | CDC Union 描述符指向不存在的接口号,或干脆缺少 CDC 功能描述符 | 回落到「接口 0 = control、接口 1 = data」的硬编码假设(同 Linux 的 `android_rndis_quirk`),并在日志里标明走了兜底路径 | -| **`MaxPacketsPerMessage = 0`** | 部分设备汇报 0 | 当 1 处理 | -| **高通上行聚合补丁** | `MaxPacketsPerMessage` 变成模块参数,默认 3 | 原样采用,TX 侧会聚合 3 包 | -| **`PacketAlignmentFactor` 越界** | 汇报 > 7 的值 | 钳到 7。不钳位的话 `1u << 31` 会算出荒谬的填充长度 | -| **`MaxTransferSize` 过小** | HTC Diamond 报 1536 < `hard_mtu`(1558) | 反推并下调 MTU 到 `1536 - 58 = 1478` | -| **`MaxTransferSize` 过大** | WinCE / Windows Mobile 习惯报 8 KB 或 16 KB 巨帧 | 钳到主机自己的缓冲上限,不盲从设备 | -| **主线 gadget 的典型值** | `MaxPacketsPerTransfer=1`、`MaxTransferSize=1580`、`PacketAlignmentFactor=0`、`DeviceFlags=CONNECTIONLESS`、`Medium=802_3` | 可作为测试基线(`tests/mock_control_channel.h` 里的 `MakeWellBehavedDevice` 就是按它造的) | +| **Broken Android CDC Union** | The CDC Union descriptor points at a non-existent interface number, or the CDC functional descriptor is missing entirely | Falls back to the hardcoded assumption "interface 0 = control, interface 1 = data" (same as Linux's `android_rndis_quirk`), and logs that the fallback path was taken | +| **`MaxPacketsPerMessage = 0`** | Some devices report 0 | Treat it as 1 | +| **Qualcomm upstream aggregation patch** | `MaxPacketsPerMessage` becomes a module parameter, default 3 | Adopted as reported; the TX side aggregates 3 packets | +| **`PacketAlignmentFactor` out of range** | Reports a value > 7 | Clamp to 7. Without clamping, `1u << 31` would compute an absurd padding length | +| **`MaxTransferSize` too small** | The HTC Diamond reports 1536 < `hard_mtu` (1558) | Derive backwards and lower the MTU to `1536 - 58 = 1478` | +| **`MaxTransferSize` too large** | WinCE / Windows Mobile devices tend to advertise 8 KB or 16 KB giant frames | Clamp to the host's own buffer cap; do not follow the device blindly | +| **Typical mainline gadget values** | `MaxPacketsPerMessage=1`, `MaxTransferSize=1580`, `PacketAlignmentFactor=0`, `DeviceFlags=CONNECTIONLESS`, `Medium=802_3` | Can serve as a test baseline (`MakeWellBehavedDevice` in `tests/mock_control_channel.h` is built to it, except that it advertises `MaxTransferSize=2048`) | --- -## 11. 超时常量 +## 11. Timeout constants -| 常量 | 规范值 | 本项目 | 理由 | +| Constant | Spec value | This project | Rationale | |---|---|---|---| -| `ControlTimeoutPeriod` | 10 秒 | **5 秒** | Linux 因 ActiveSync 的行为收缩到 5 秒;真要 10 秒才回,链路已经不可用 | -| `KeepAliveTimeoutPeriod` | 5 秒 | 5 秒 | 语义是「距上次从设备收到**任何**消息已过 5 秒」才发,不是无条件定时发 | -| 控制缓冲区 | ≥ 1024 字节 | **1536** | Windows 用 1025 这个奇怪的值,Linux 照抄;取 1536 留余量 | - -⚠️ Apple Silicon 上 libusb 的**控制传输**比 x64 慢约 10 倍 -([libusb issue #1288](https://github.com/libusb/libusb/issues/1288)), -所以保活周期别调太小 —— 每次往返都是毫秒级预算。 +| `ControlTimeoutPeriod` | 10 seconds | **5 seconds** | Linux shrank it to 5 seconds because of ActiveSync behavior; if a reply really takes 10 seconds, the link is already unusable | +| `KeepAliveTimeoutPeriod` | 5 seconds | 5 seconds | The semantics are "send only when **any** message from the device has not arrived for 5 seconds", not an unconditional timer | +| Control buffer | >= 1024 bytes | **1536** | Windows uses the odd value 1025 and Linux copies it; 1536 leaves headroom | + +**Warning:** on Apple Silicon, libusb **control transfers** are about 10x slower +than on x64 +([libusb issue #1288](https://github.com/libusb/libusb/issues/1288)), so do not +set the keep-alive period too low -- every round trip is on a millisecond +budget. From 086eaf06f72363f54cca9cd02b84b8d18c0590b5 Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:58:52 +0530 Subject: [PATCH 5/6] style: satisfy the CI format gate on the touched test files --- tests/test_common_i18n.cc | 186 ++++++------ tests/test_core_bridge.cc | 96 +++--- tests/test_net_link.cc | 22 +- tests/test_rndis_packet_codec.cc | 72 ++--- tests/test_rndis_state_machine.cc | 478 +++++++++++++++--------------- 5 files changed, 427 insertions(+), 427 deletions(-) diff --git a/tests/test_common_i18n.cc b/tests/test_common_i18n.cc index 7c6840f..820b1e9 100644 --- a/tests/test_common_i18n.cc +++ b/tests/test_common_i18n.cc @@ -139,119 +139,119 @@ struct ParsedFormat { } // namespace TEST_SUITE("common.i18n") { + TEST_CASE("language switch and table lookup") { + const Language original = tetherkit::GetLanguage(); -TEST_CASE("language switch and table lookup") { - const Language original = tetherkit::GetLanguage(); + // The selection machinery is process-wide and round-trips; today every + // value resolves to the English table, but these calls exercise the same + // path a second language will ride. + tetherkit::SetLanguage(Language::kEnglish); + CHECK(tetherkit::GetLanguage() == Language::kEnglish); + CHECK(tetherkit::Text(Msg::kCoreRunStateRunning) == "running"); - // The selection machinery is process-wide and round-trips; today every - // value resolves to the English table, but these calls exercise the same - // path a second language will ride. - tetherkit::SetLanguage(Language::kEnglish); - CHECK(tetherkit::GetLanguage() == Language::kEnglish); - CHECK(tetherkit::Text(Msg::kCoreRunStateRunning) == "running"); - - tetherkit::SetLanguage(original); -} + tetherkit::SetLanguage(original); + } -TEST_CASE("Tr renders the current language and substitutes arguments") { - const Language original = tetherkit::GetLanguage(); - tetherkit::SetLanguage(Language::kEnglish); + TEST_CASE("Tr renders the current language and substitutes arguments") { + const Language original = tetherkit::GetLanguage(); + tetherkit::SetLanguage(Language::kEnglish); - const std::string text = tetherkit::Tr(Msg::kNetBpfBindFailed, "feth1"); - // The argument really was substituted, not left as an unreplaced field. - CHECK(text.find("feth1") != std::string::npos); - CHECK(text.find("{}") == std::string::npos); + const std::string text = tetherkit::Tr(Msg::kNetBpfBindFailed, "feth1"); + // The argument really was substituted, not left as an unreplaced field. + CHECK(text.find("feth1") != std::string::npos); + CHECK(text.find("{}") == std::string::npos); - tetherkit::SetLanguage(original); -} + tetherkit::SetLanguage(original); + } -TEST_CASE("every message is non-empty") { - for (std::size_t i = 0; i < kMessageCount; ++i) { - const auto id = static_cast(i); - CAPTURE(i); - CHECK_FALSE(tetherkit::TextIn(Language::kEnglish, id).empty()); + TEST_CASE("every message is non-empty") { + for (std::size_t i = 0; i < kMessageCount; ++i) { + const auto id = static_cast(i); + CAPTURE(i); + CHECK_FALSE(tetherkit::TextIn(Language::kEnglish, id).empty()); + } } -} -TEST_CASE("out-of-range Msg returns an empty string instead of crashing") { - const auto out_of_range = static_cast(kMessageCount); - CHECK(tetherkit::Text(out_of_range).empty()); - CHECK(tetherkit::TextIn(Language::kEnglish, out_of_range).empty()); -} + TEST_CASE("out-of-range Msg returns an empty string instead of crashing") { + const auto out_of_range = static_cast(kMessageCount); + CHECK(tetherkit::Text(out_of_range).empty()); + CHECK(tetherkit::TextIn(Language::kEnglish, out_of_range).empty()); + } -// This suite's core: the one check the compiler stopped doing, done entry by -// entry. -TEST_CASE("every message's placeholders are well-formed and contiguous") { - for (std::size_t i = 0; i < kMessageCount; ++i) { - const ParsedFormat parsed = ParseFormat(tetherkit::TextIn(Language::kEnglish, static_cast(i))); - - CAPTURE(Label(i)); - CHECK_FALSE(parsed.malformed); - // Mixing `{}` with `{0}` in one string makes std::format throw at - // runtime; nothing at compile time can stop it. - CHECK_FALSE(parsed.mixed_indexing); - - // Indices must be exactly 0..n-1: a gap still renders, but it means an - // argument was silently ignored, which is almost always a mistake. - std::size_t expected = 0; - for (const auto& entry : parsed.types) { - CHECK(entry.first == expected); - ++expected; + // This suite's core: the one check the compiler stopped doing, done entry by + // entry. + TEST_CASE("every message's placeholders are well-formed and contiguous") { + for (std::size_t i = 0; i < kMessageCount; ++i) { + const ParsedFormat parsed = + ParseFormat(tetherkit::TextIn(Language::kEnglish, static_cast(i))); + + CAPTURE(Label(i)); + CHECK_FALSE(parsed.malformed); + // Mixing `{}` with `{0}` in one string makes std::format throw at + // runtime; nothing at compile time can stop it. + CHECK_FALSE(parsed.mixed_indexing); + + // Indices must be exactly 0..n-1: a gap still renders, but it means an + // argument was silently ignored, which is almost always a mistake. + std::size_t expected = 0; + for (const auto& entry : parsed.types) { + CHECK(entry.first == expected); + ++expected; + } } } -} -TEST_CASE("ParseLanguage understands the established spellings") { - Language language = Language::kEnglish; + TEST_CASE("ParseLanguage understands the established spellings") { + Language language = Language::kEnglish; - CHECK(tetherkit::ParseLanguage("en", &language)); - CHECK(language == Language::kEnglish); - CHECK(tetherkit::ParseLanguage("EN_US", &language)); - CHECK(language == Language::kEnglish); - CHECK(tetherkit::ParseLanguage("english", &language)); - CHECK(language == Language::kEnglish); + CHECK(tetherkit::ParseLanguage("en", &language)); + CHECK(language == Language::kEnglish); + CHECK(tetherkit::ParseLanguage("EN_US", &language)); + CHECK(language == Language::kEnglish); + CHECK(tetherkit::ParseLanguage("english", &language)); + CHECK(language == Language::kEnglish); - // auto defers to the environment; only require it to succeed and yield a - // valid language. - CHECK(tetherkit::ParseLanguage("auto", &language)); + // auto defers to the environment; only require it to succeed and yield a + // valid language. + CHECK(tetherkit::ParseLanguage("auto", &language)); - CHECK_FALSE(tetherkit::ParseLanguage("klingon", &language)); - CHECK_FALSE(tetherkit::ParseLanguage("", &language)); - CHECK_FALSE(tetherkit::ParseLanguage("zh", nullptr)); + CHECK_FALSE(tetherkit::ParseLanguage("klingon", &language)); + CHECK_FALSE(tetherkit::ParseLanguage("", &language)); + CHECK_FALSE(tetherkit::ParseLanguage("zh", nullptr)); - // Chinese is fully out: no table, no spelling, not even as a parse. - CHECK_FALSE(tetherkit::ParseLanguage("zh", &language)); - CHECK_FALSE(tetherkit::ParseLanguage("Chinese", &language)); -} + // Chinese is fully out: no table, no spelling, not even as a parse. + CHECK_FALSE(tetherkit::ParseLanguage("zh", &language)); + CHECK_FALSE(tetherkit::ParseLanguage("Chinese", &language)); + } -TEST_CASE("locale strings resolve through the current language set") { - // Documents today's behavior: with English as the only language, every - // locale string resolves to English. When a language is added, the prefix - // table goes into LanguageFromLocaleString() and these checks become - // per-prefix. - CHECK(tetherkit::LanguageFromLocaleString("zh_CN.UTF-8") == Language::kEnglish); - CHECK(tetherkit::LanguageFromLocaleString("ZH-hant") == Language::kEnglish); - CHECK(tetherkit::LanguageFromLocaleString("en_US.UTF-8") == Language::kEnglish); - CHECK(tetherkit::LanguageFromLocaleString("C") == Language::kEnglish); - CHECK(tetherkit::LanguageFromLocaleString("") == Language::kEnglish); - CHECK(tetherkit::LanguageFromLocaleString("en_zh") == Language::kEnglish); -} + TEST_CASE("locale strings resolve through the current language set") { + // Documents today's behavior: with English as the only language, every + // locale string resolves to English. When a language is added, the prefix + // table goes into LanguageFromLocaleString() and these checks become + // per-prefix. + CHECK(tetherkit::LanguageFromLocaleString("zh_CN.UTF-8") == Language::kEnglish); + CHECK(tetherkit::LanguageFromLocaleString("ZH-hant") == Language::kEnglish); + CHECK(tetherkit::LanguageFromLocaleString("en_US.UTF-8") == Language::kEnglish); + CHECK(tetherkit::LanguageFromLocaleString("C") == Language::kEnglish); + CHECK(tetherkit::LanguageFromLocaleString("") == Language::kEnglish); + CHECK(tetherkit::LanguageFromLocaleString("en_zh") == Language::kEnglish); + } -TEST_CASE("language tag") { - CHECK(tetherkit::LanguageTag(Language::kEnglish) == "en"); -} + TEST_CASE("language tag") { + CHECK(tetherkit::LanguageTag(Language::kEnglish) == "en"); + } -TEST_CASE("error context separator is deterministic") { - const Language original = tetherkit::GetLanguage(); - tetherkit::SetLanguage(Language::kEnglish); + TEST_CASE("error context separator is deterministic") { + const Language original = tetherkit::GetLanguage(); + tetherkit::SetLanguage(Language::kEnglish); - // Context() returns a view, so the Error must land in a named variable - // before it is read; a temporary dies at the end of the statement and the - // view dangles on the spot. - const tetherkit::Error error = tetherkit::Error::Generic("inner").WithContext("outer"); - CHECK(error.Context().starts_with("outer: ")); + // Context() returns a view, so the Error must land in a named variable + // before it is read; a temporary dies at the end of the statement and the + // view dangles on the spot. + const tetherkit::Error error = tetherkit::Error::Generic("inner").WithContext("outer"); + CHECK(error.Context().starts_with("outer: ")); - tetherkit::SetLanguage(original); -} + tetherkit::SetLanguage(original); + } } // TEST_SUITE diff --git a/tests/test_core_bridge.cc b/tests/test_core_bridge.cc index 35ee0fa..af2c733 100644 --- a/tests/test_core_bridge.cc +++ b/tests/test_core_bridge.cc @@ -507,57 +507,57 @@ TEST_SUITE("core.bridge") { bridge.Stop(); } -TEST_CASE("stats line renders rates and drops") { - // The stats line is user-facing text; pin the language before asserting. - const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kEnglish}; - MockDataChannel channel; - LoopbackLink link(LoopbackConfig{.sent_capacity = 4096}); - Bridge bridge(channel, link, TestConfig()); - REQUIRE(bridge.Start().has_value()); - - const BridgeStats before = bridge.Snapshot(); - constexpr std::uint32_t kCount = 50; - CHECK(channel.InjectFromDevice(MakeFrames(kCount, 1514)) == kCount); - REQUIRE(WaitFor([&] { return link.TotalSentFrames() >= kCount; })); - const BridgeStats after = bridge.Snapshot(); - - const std::string line = FormatStatsLine(before, after, 1.0); - CHECK(line.find("RX") != std::string::npos); - CHECK(line.find("TX") != std::string::npos); - CHECK(line.find("pps") != std::string::npos); - CHECK(line.find("Mbps") != std::string::npos); - CHECK(line.find("queue depth") != std::string::npos); - - bridge.Stop(); -} + TEST_CASE("stats line renders rates and drops") { + // The stats line is user-facing text; pin the language before asserting. + const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kEnglish}; + MockDataChannel channel; + LoopbackLink link(LoopbackConfig{.sent_capacity = 4096}); + Bridge bridge(channel, link, TestConfig()); + REQUIRE(bridge.Start().has_value()); + + const BridgeStats before = bridge.Snapshot(); + constexpr std::uint32_t kCount = 50; + CHECK(channel.InjectFromDevice(MakeFrames(kCount, 1514)) == kCount); + REQUIRE(WaitFor([&] { return link.TotalSentFrames() >= kCount; })); + const BridgeStats after = bridge.Snapshot(); + + const std::string line = FormatStatsLine(before, after, 1.0); + CHECK(line.find("RX") != std::string::npos); + CHECK(line.find("TX") != std::string::npos); + CHECK(line.find("pps") != std::string::npos); + CHECK(line.find("Mbps") != std::string::npos); + CHECK(line.find("queue depth") != std::string::npos); + + bridge.Stop(); + } + + TEST_CASE("Shutdown keeps the statistics already queued, and stops safely under traffic") { + MockDataChannel channel; + LoopbackLink link(LoopbackConfig{ + .inbound_capacity = 8192, .sent_capacity = 8192, .max_frames_per_batch = 64}); + Bridge bridge(channel, link, TestConfig()); + REQUIRE(bridge.Start().has_value()); -TEST_CASE("Shutdown keeps the statistics already queued, and stops safely under traffic") { - MockDataChannel channel; - LoopbackLink link(LoopbackConfig{.inbound_capacity = 8192, .sent_capacity = 8192, - .max_frames_per_batch = 64}); - Bridge bridge(channel, link, TestConfig()); - REQUIRE(bridge.Start().has_value()); - - // Stopping while traffic flows -- this is the scenario that exposes - // teardown-order problems most easily. - std::atomic keep_going{true}; - std::thread producer([&] { - while (keep_going.load(std::memory_order_acquire)) { - (void)channel.InjectFromDevice(MakeFrames(8, 512)); - for (const std::vector& frame : MakeFrames(8, 512)) { - (void)link.PushInbound(frame); + // Stopping while traffic flows -- this is the scenario that exposes + // teardown-order problems most easily. + std::atomic keep_going{true}; + std::thread producer([&] { + while (keep_going.load(std::memory_order_acquire)) { + (void)channel.InjectFromDevice(MakeFrames(8, 512)); + for (const std::vector& frame : MakeFrames(8, 512)) { + (void)link.PushInbound(frame); + } + std::this_thread::yield(); } - std::this_thread::yield(); - } - }); + }); - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - bridge.Stop(); // stop while traffic flows - keep_going.store(false, std::memory_order_release); - producer.join(); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + bridge.Stop(); // stop while traffic flows + keep_going.store(false, std::memory_order_release); + producer.join(); - CHECK_FALSE(bridge.Running()); - CHECK(channel.ShutdownCalled()); -} + CHECK_FALSE(bridge.Running()); + CHECK(channel.ShutdownCalled()); + } } // TEST_SUITE("core.bridge") diff --git a/tests/test_net_link.cc b/tests/test_net_link.cc index 39087d2..1c8f0f8 100644 --- a/tests/test_net_link.cc +++ b/tests/test_net_link.cc @@ -228,18 +228,18 @@ TEST_SUITE("net.abi") { CHECK(BPF_WORDALIGN(14 + kBpfHeaderMinBytes) - 14 == kBpfHeaderMinBytes); } -TEST_CASE("feth creation-time sysctl list is non-empty and each entry has a reason") { - CHECK(std::size(kRequiredFethSysctls) > 0); - for (const RequiredFethSysctl& entry : kRequiredFethSysctls) { - CHECK(entry.name != nullptr); - // why holds a message identifier; the shipped language must really have - // text for it (a missing row would render as an empty string and leave - // "reason:" empty in sysctl errors). - const std::string_view why = tetherkit::TextIn(tetherkit::Language::kEnglish, entry.why); - // The reason must be a real sentence, not one word. - CHECK(why.size() > 10); + TEST_CASE("feth creation-time sysctl list is non-empty and each entry has a reason") { + CHECK(std::size(kRequiredFethSysctls) > 0); + for (const RequiredFethSysctl& entry : kRequiredFethSysctls) { + CHECK(entry.name != nullptr); + // why holds a message identifier; the shipped language must really have + // text for it (a missing row would render as an empty string and leave + // "reason:" empty in sysctl errors). + const std::string_view why = tetherkit::TextIn(tetherkit::Language::kEnglish, entry.why); + // The reason must be a real sentence, not one word. + CHECK(why.size() > 10); + } } -} } // TEST_SUITE("net.abi") diff --git a/tests/test_rndis_packet_codec.cc b/tests/test_rndis_packet_codec.cc index c9679d8..01e925f 100644 --- a/tests/test_rndis_packet_codec.cc +++ b/tests/test_rndis_packet_codec.cc @@ -439,46 +439,46 @@ TEST_SUITE("rndis.packet_codec") { CHECK(reader.BytesConsumed() == kPacketMsgHeaderBytes + 100); } -TEST_CASE("MalformedReason names are complete") { - // The names are user-facing text and follow the language, so pin it first. - const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kEnglish}; - CHECK(MalformedReasonName(MalformedReason::kNone) == "none"); - CHECK_FALSE(MalformedReasonName(MalformedReason::kNotPacketMessage).empty()); - CHECK_FALSE(MalformedReasonName(MalformedReason::kDataOutOfBounds).empty()); - CHECK_FALSE(MalformedReasonName(MalformedReason::kFrameTooLong).empty()); -} + TEST_CASE("MalformedReason names are complete") { + // The names are user-facing text and follow the language, so pin it first. + const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kEnglish}; + CHECK(MalformedReasonName(MalformedReason::kNone) == "none"); + CHECK_FALSE(MalformedReasonName(MalformedReason::kNotPacketMessage).empty()); + CHECK_FALSE(MalformedReasonName(MalformedReason::kDataOutOfBounds).empty()); + CHECK_FALSE(MalformedReasonName(MalformedReason::kFrameTooLong).empty()); + } + + // --------------------------------------------------------------------------- + // Round-trip property tests + // --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// Round-trip property tests -// --------------------------------------------------------------------------- - -TEST_CASE("Round trip: every alignment factor and frame length is reproduced losslessly") { - std::array transfer{}; - for (std::uint32_t factor = 0; factor <= kMaxPacketAlignmentFactor; ++factor) { - const std::uint32_t alignment = 1U << factor; - for (const std::uint32_t length : {14U, 15U, 63U, 64U, 65U, 127U, 128U, 1514U}) { - PacketMessageWriter writer( - transfer, - {.max_transfer_bytes = 16384, .max_messages = 4, .alignment_bytes = alignment}, 512); - - std::uint32_t appended = 0; - for (std::uint8_t i = 0; i < 4; ++i) { - if (!writer.TryAppend(MakeFrame(length, static_cast(0x10 + i)))) { - break; + TEST_CASE("Round trip: every alignment factor and frame length is reproduced losslessly") { + std::array transfer{}; + for (std::uint32_t factor = 0; factor <= kMaxPacketAlignmentFactor; ++factor) { + const std::uint32_t alignment = 1U << factor; + for (const std::uint32_t length : {14U, 15U, 63U, 64U, 65U, 127U, 128U, 1514U}) { + PacketMessageWriter writer( + transfer, + {.max_transfer_bytes = 16384, .max_messages = 4, .alignment_bytes = alignment}, 512); + + std::uint32_t appended = 0; + for (std::uint8_t i = 0; i < 4; ++i) { + if (!writer.TryAppend(MakeFrame(length, static_cast(0x10 + i)))) { + break; + } + ++appended; + } + REQUIRE(appended > 0); + const std::uint32_t total = writer.Finish(); + + const auto summary = DecodeAll(std::span{transfer}.first(total)); + CHECK(summary.final_outcome == ReadOutcome::kEndOfTransfer); + REQUIRE(summary.frames.size() == appended); + for (std::uint32_t i = 0; i < appended; ++i) { + CHECK(FrameMatches(summary.frames[i], length, static_cast(0x10 + i))); } - ++appended; - } - REQUIRE(appended > 0); - const std::uint32_t total = writer.Finish(); - - const auto summary = DecodeAll(std::span{transfer}.first(total)); - CHECK(summary.final_outcome == ReadOutcome::kEndOfTransfer); - REQUIRE(summary.frames.size() == appended); - for (std::uint32_t i = 0; i < appended; ++i) { - CHECK(FrameMatches(summary.frames[i], length, static_cast(0x10 + i))); } } } -} } // TEST_SUITE("rndis.packet_codec") diff --git a/tests/test_rndis_state_machine.cc b/tests/test_rndis_state_machine.cc index 2e773b7..ba61c9a 100644 --- a/tests/test_rndis_state_machine.cc +++ b/tests/test_rndis_state_machine.cc @@ -341,260 +341,260 @@ TEST_SUITE("rndis.state_machine") { CHECK_MESSAGE(found, "the host did not reply to the device-initiated KEEPALIVE"); } -TEST_CASE("messages aimed at connection-oriented devices are explicitly rejected") { - // The error message wording is asserted below; pin the language. - const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kEnglish}; - MockControlChannel channel; - channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { - if (LoadLe32(request.data() + kMessageTypeOffset) == ToRaw(MessageType::kInitialize)) { - // Queue a CONDIS message. - std::vector condis(kMessageHeaderBytes); - StoreLe32(condis.data() + kMessageTypeOffset, 0x0000'8001U); // MP_CREATE_VC - StoreLe32(condis.data() + kMessageLengthOffset, kMessageHeaderBytes); - mock.EnqueueResponse(std::move(condis)); - mock.EnqueueResponse(MakeInitializeComplete(RequestIdOf(request))); - } - }); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - - const auto status = machine.Start(); - REQUIRE_FALSE(status.has_value()); - // The message must explicitly name connection-oriented devices rather - // than the vague "unknown message". - CHECK(status.error().ToString().find("connection-oriented") != std::string::npos); -} + TEST_CASE("messages aimed at connection-oriented devices are explicitly rejected") { + // The error message wording is asserted below; pin the language. + const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kEnglish}; + MockControlChannel channel; + channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { + if (LoadLe32(request.data() + kMessageTypeOffset) == ToRaw(MessageType::kInitialize)) { + // Queue a CONDIS message. + std::vector condis(kMessageHeaderBytes); + StoreLe32(condis.data() + kMessageTypeOffset, 0x0000'8001U); // MP_CREATE_VC + StoreLe32(condis.data() + kMessageLengthOffset, kMessageHeaderBytes); + mock.EnqueueResponse(std::move(condis)); + mock.EnqueueResponse(MakeInitializeComplete(RequestIdOf(request))); + } + }); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); -TEST_CASE("keepalive success reports no error and sends none while the device is active") { - MockControlChannel channel; - channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); - RecordingObserver observer; - auto config = FastConfig(); - // Keep-alive period set very large: Poll must not send a keep-alive. - config.keepalive_interval_millis = 60'000; - StateMachine machine(channel, observer, config); - REQUIRE(machine.Start().has_value()); - - channel.ClearSentMessages(); - REQUIRE(machine.Poll().has_value()); - CHECK(channel.CountSent(MessageType::kKeepAlive) == 0); -} + const auto status = machine.Start(); + REQUIRE_FALSE(status.has_value()); + // The message must explicitly name connection-oriented devices rather + // than the vague "unknown message". + CHECK(status.error().ToString().find("connection-oriented") != std::string::npos); + } -TEST_CASE("keepalive expiry sends KEEPALIVE and clears the failure count after success") { - MockControlChannel channel; - channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); - RecordingObserver observer; - auto config = FastConfig(); - config.keepalive_interval_millis = 0; // expires immediately - StateMachine machine(channel, observer, config); - REQUIRE(machine.Start().has_value()); - - channel.ClearSentMessages(); - REQUIRE(machine.Poll().has_value()); - CHECK(channel.CountSent(MessageType::kKeepAlive) == 1); - CHECK(observer.fatal_errors.empty()); -} + TEST_CASE("keepalive success reports no error and sends none while the device is active") { + MockControlChannel channel; + channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); + RecordingObserver observer; + auto config = FastConfig(); + // Keep-alive period set very large: Poll must not send a keep-alive. + config.keepalive_interval_millis = 60'000; + StateMachine machine(channel, observer, config); + REQUIRE(machine.Start().has_value()); -TEST_CASE("reaching the keepalive failure threshold reports a fatal error") { - // The fatal error wording is asserted below; pin the language. - const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kEnglish}; - MockControlChannel channel; - // The device answers only INITIALIZE / QUERY / SET and never KEEPALIVE -> - // every poll times out. - channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { - const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); - if (type == ToRaw(MessageType::kKeepAlive)) { - return; // deliberately no reply - } - MakeWellBehavedDevice(kDeviceMac)(request, mock); - }); - RecordingObserver observer; - auto config = FastConfig(); - config.keepalive_interval_millis = 0; - config.keepalive_failure_threshold = 3; - StateMachine machine(channel, observer, config); - REQUIRE(machine.Start().has_value()); - - // The first two failures must not be fatal. - CHECK(machine.Poll().has_value()); - CHECK(observer.fatal_errors.empty()); - CHECK(machine.Poll().has_value()); - CHECK(observer.fatal_errors.empty()); - // The third one reaches the threshold. - const auto third = machine.Poll(); - CHECK_FALSE(third.has_value()); - REQUIRE(observer.fatal_errors.size() == 1); - CHECK(observer.fatal_errors[0].find("keep-alive") != std::string::npos); -} + channel.ClearSentMessages(); + REQUIRE(machine.Poll().has_value()); + CHECK(channel.CountSent(MessageType::kKeepAlive) == 0); + } -TEST_CASE("Soft reset: when AddressingReset is non-zero the packet filter must be replayed") { - // This is the easiest one to miss: without the replay, data never flows - // again after the reset. - MockControlChannel channel; - channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - REQUIRE(machine.Start().has_value()); - - channel.ClearSentMessages(); - REQUIRE(machine.Reset().has_value()); - - // The reset was reported, and it flags the addressing information as lost. - REQUIRE(observer.reset_events.size() == 1); - CHECK(observer.reset_events[0]); - - // A RESET_MSG must have been sent. - CHECK(channel.CountSent(MessageType::kReset) == 1); - // And the packet filter must have been replayed. - std::uint32_t filter = 0; - REQUIRE_MESSAGE(channel.FindSetUint32(Oid::kGenCurrentPacketFilter, filter), - "the packet filter setting was not replayed after the reset"); - CHECK(filter == kDefaultPacketFilter); -} + TEST_CASE("keepalive expiry sends KEEPALIVE and clears the failure count after success") { + MockControlChannel channel; + channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); + RecordingObserver observer; + auto config = FastConfig(); + config.keepalive_interval_millis = 0; // expires immediately + StateMachine machine(channel, observer, config); + REQUIRE(machine.Start().has_value()); -TEST_CASE("Soft reset: when AddressingReset is 0 no replay is needed") { - MockControlChannel channel; - channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { - const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); - if (type == ToRaw(MessageType::kReset)) { - mock.EnqueueResponse(MakeResetComplete(/*addressing_reset=*/false)); - return; - } - MakeWellBehavedDevice(kDeviceMac)(request, mock); - }); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - REQUIRE(machine.Start().has_value()); - - channel.ClearSentMessages(); - REQUIRE(machine.Reset().has_value()); - REQUIRE(observer.reset_events.size() == 1); - CHECK_FALSE(observer.reset_events[0]); - - std::uint32_t filter = 0; - CHECK_FALSE(channel.FindSetUint32(Oid::kGenCurrentPacketFilter, filter)); -} + channel.ClearSentMessages(); + REQUIRE(machine.Poll().has_value()); + CHECK(channel.CountSent(MessageType::kKeepAlive) == 1); + CHECK(observer.fatal_errors.empty()); + } -TEST_CASE("Shutdown: clear the packet filter first, then send HALT") { - MockControlChannel channel; - channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - REQUIRE(machine.Start().has_value()); - - channel.ClearSentMessages(); - machine.Stop(); - CHECK(machine.CurrentState() == State::kUninitialized); - - // Must SET filter = 0 first (so the device falls back to initialized and - // stops sending data), then send HALT. - std::uint32_t filter = 0xFFFF'FFFFU; - REQUIRE(channel.FindSetUint32(Oid::kGenCurrentPacketFilter, filter)); - CHECK(filter == 0); - CHECK(channel.CountSent(MessageType::kHalt) == 1); - - const std::size_t last = channel.SentMessages().size() - 1; - CHECK(channel.SentMessageType(last) == ToRaw(MessageType::kHalt)); -} + TEST_CASE("reaching the keepalive failure threshold reports a fatal error") { + // The fatal error wording is asserted below; pin the language. + const tetherkit::testing::ScopedLanguage guard{tetherkit::Language::kEnglish}; + MockControlChannel channel; + // The device answers only INITIALIZE / QUERY / SET and never KEEPALIVE -> + // every poll times out. + channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { + const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); + if (type == ToRaw(MessageType::kKeepAlive)) { + return; // deliberately no reply + } + MakeWellBehavedDevice(kDeviceMac)(request, mock); + }); + RecordingObserver observer; + auto config = FastConfig(); + config.keepalive_interval_millis = 0; + config.keepalive_failure_threshold = 3; + StateMachine machine(channel, observer, config); + REQUIRE(machine.Start().has_value()); -TEST_CASE("Shutdown is idempotent") { - MockControlChannel channel; - channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - REQUIRE(machine.Start().has_value()); - - machine.Stop(); - const std::size_t after_first = channel.SentMessages().size(); - machine.Stop(); - CHECK(channel.SentMessages().size() == after_first); -} + // The first two failures must not be fatal. + CHECK(machine.Poll().has_value()); + CHECK(observer.fatal_errors.empty()); + CHECK(machine.Poll().has_value()); + CHECK(observer.fatal_errors.empty()); + // The third one reaches the threshold. + const auto third = machine.Poll(); + CHECK_FALSE(third.has_value()); + REQUIRE(observer.fatal_errors.size() == 1); + CHECK(observer.fatal_errors[0].find("keep-alive") != std::string::npos); + } -TEST_CASE("Start may only be called in the uninitialized state") { - MockControlChannel channel; - channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - REQUIRE(machine.Start().has_value()); - CHECK_FALSE(machine.Start().has_value()); -} + TEST_CASE("Soft reset: when AddressingReset is non-zero the packet filter must be replayed") { + // This is the easiest one to miss: without the replay, data never flows + // again after the reset. + MockControlChannel channel; + channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + REQUIRE(machine.Start().has_value()); -TEST_CASE("A device without an interrupt endpoint degrades to polling, startup still completes") { - // The Linux host driver ignores the interrupt endpoint entirely and relies - // on polling the control endpoint. - MockControlChannel channel; - channel.SetHasInterruptEndpoint(false); - channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - - const auto status = machine.Start(); - REQUIRE_MESSAGE(status.has_value(), Why(status)); - CHECK(machine.CurrentState() == State::kDataInitialized); -} + channel.ClearSentMessages(); + REQUIRE(machine.Reset().has_value()); -TEST_CASE("A control channel send failure fails startup without getting stuck") { - MockControlChannel channel; - channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); - channel.FailSendOnCall(1); // the very first INITIALIZE cannot be sent - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); + // The reset was reported, and it flags the addressing information as lost. + REQUIRE(observer.reset_events.size() == 1); + CHECK(observer.reset_events[0]); - REQUIRE_FALSE(machine.Start().has_value()); - CHECK(machine.CurrentState() == State::kUninitialized); -} + // A RESET_MSG must have been sent. + CHECK(channel.CountSent(MessageType::kReset) == 1); + // And the packet filter must have been replayed. + std::uint32_t filter = 0; + REQUIRE_MESSAGE(channel.FindSetUint32(Oid::kGenCurrentPacketFilter, filter), + "the packet filter setting was not replayed after the reset"); + CHECK(filter == kDefaultPacketFilter); + } -TEST_CASE("Device quirk: Qualcomm aggregation patch (MaxPacketsPerMessage=3, alignment factor 2)") { - MockControlChannel channel; - channel.SetRequestHandler( - MakeWellBehavedDevice(kDeviceMac, /*max_transfer_size=*/3 * 1600, - /*max_packets=*/3, /*alignment_factor=*/2)); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - REQUIRE(machine.Start().has_value()); - - CHECK(observer.parameters_snapshot.max_packets_per_message == 3); - CHECK(observer.parameters_snapshot.tx_alignment_bytes == 4); // 1 << 2 -} + TEST_CASE("Soft reset: when AddressingReset is 0 no replay is needed") { + MockControlChannel channel; + channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { + const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); + if (type == ToRaw(MessageType::kReset)) { + mock.EnqueueResponse(MakeResetComplete(/*addressing_reset=*/false)); + return; + } + MakeWellBehavedDevice(kDeviceMac)(request, mock); + }); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + REQUIRE(machine.Start().has_value()); -TEST_CASE("Device quirk: an out-of-range alignment factor is clamped to 7") { - MockControlChannel channel; - channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac, 2048, 1, /*alignment_factor=*/31)); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - REQUIRE(machine.Start().has_value()); - CHECK(observer.parameters_snapshot.tx_alignment_bytes == 128); -} + channel.ClearSentMessages(); + REQUIRE(machine.Reset().has_value()); + REQUIRE(observer.reset_events.size() == 1); + CHECK_FALSE(observer.reset_events[0]); -TEST_CASE("The MTU drops when the device's max frame size is below the negotiated MTU") { - MockControlChannel channel; - channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { - const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); - const std::uint32_t request_id = RequestIdOf(request); - if (type == ToRaw(MessageType::kQuery) && - OidOf(request) == ToRaw(Oid::kGenMaximumFrameSize)) { - mock.EnqueueResponse(MakeQueryCompleteUint32(request_id, 1400)); - return; - } - MakeWellBehavedDevice(kDeviceMac)(request, mock); - }); - RecordingObserver observer; - StateMachine machine(channel, observer, FastConfig()); - REQUIRE(machine.Start().has_value()); - CHECK(machine.Parameters().mtu == 1400); -} + std::uint32_t filter = 0; + CHECK_FALSE(channel.FindSetUint32(Oid::kGenCurrentPacketFilter, filter)); + } -TEST_CASE("MillisUntilNextPoll returns a positive value within the keep-alive period") { - MockControlChannel channel; - channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); - RecordingObserver observer; - auto config = FastConfig(); - config.keepalive_interval_millis = 5'000; - StateMachine machine(channel, observer, config); - REQUIRE(machine.Start().has_value()); - CHECK(machine.MillisUntilNextPoll() > 0); - CHECK(machine.MillisUntilNextPoll() <= 5'000); -} + TEST_CASE("Shutdown: clear the packet filter first, then send HALT") { + MockControlChannel channel; + channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + REQUIRE(machine.Start().has_value()); + + channel.ClearSentMessages(); + machine.Stop(); + CHECK(machine.CurrentState() == State::kUninitialized); + + // Must SET filter = 0 first (so the device falls back to initialized and + // stops sending data), then send HALT. + std::uint32_t filter = 0xFFFF'FFFFU; + REQUIRE(channel.FindSetUint32(Oid::kGenCurrentPacketFilter, filter)); + CHECK(filter == 0); + CHECK(channel.CountSent(MessageType::kHalt) == 1); + + const std::size_t last = channel.SentMessages().size() - 1; + CHECK(channel.SentMessageType(last) == ToRaw(MessageType::kHalt)); + } + + TEST_CASE("Shutdown is idempotent") { + MockControlChannel channel; + channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + REQUIRE(machine.Start().has_value()); + + machine.Stop(); + const std::size_t after_first = channel.SentMessages().size(); + machine.Stop(); + CHECK(channel.SentMessages().size() == after_first); + } + + TEST_CASE("Start may only be called in the uninitialized state") { + MockControlChannel channel; + channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + REQUIRE(machine.Start().has_value()); + CHECK_FALSE(machine.Start().has_value()); + } + + TEST_CASE("A device without an interrupt endpoint degrades to polling, startup still completes") { + // The Linux host driver ignores the interrupt endpoint entirely and relies + // on polling the control endpoint. + MockControlChannel channel; + channel.SetHasInterruptEndpoint(false); + channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + + const auto status = machine.Start(); + REQUIRE_MESSAGE(status.has_value(), Why(status)); + CHECK(machine.CurrentState() == State::kDataInitialized); + } + + TEST_CASE("A control channel send failure fails startup without getting stuck") { + MockControlChannel channel; + channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); + channel.FailSendOnCall(1); // the very first INITIALIZE cannot be sent + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + + REQUIRE_FALSE(machine.Start().has_value()); + CHECK(machine.CurrentState() == State::kUninitialized); + } + + TEST_CASE( + "Device quirk: Qualcomm aggregation patch (MaxPacketsPerMessage=3, alignment factor 2)") { + MockControlChannel channel; + channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac, /*max_transfer_size=*/3 * 1600, + /*max_packets=*/3, /*alignment_factor=*/2)); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + REQUIRE(machine.Start().has_value()); + + CHECK(observer.parameters_snapshot.max_packets_per_message == 3); + CHECK(observer.parameters_snapshot.tx_alignment_bytes == 4); // 1 << 2 + } + + TEST_CASE("Device quirk: an out-of-range alignment factor is clamped to 7") { + MockControlChannel channel; + channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac, 2048, 1, /*alignment_factor=*/31)); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + REQUIRE(machine.Start().has_value()); + CHECK(observer.parameters_snapshot.tx_alignment_bytes == 128); + } + + TEST_CASE("The MTU drops when the device's max frame size is below the negotiated MTU") { + MockControlChannel channel; + channel.SetRequestHandler([](std::span request, MockControlChannel& mock) { + const std::uint32_t type = LoadLe32(request.data() + kMessageTypeOffset); + const std::uint32_t request_id = RequestIdOf(request); + if (type == ToRaw(MessageType::kQuery) && + OidOf(request) == ToRaw(Oid::kGenMaximumFrameSize)) { + mock.EnqueueResponse(MakeQueryCompleteUint32(request_id, 1400)); + return; + } + MakeWellBehavedDevice(kDeviceMac)(request, mock); + }); + RecordingObserver observer; + StateMachine machine(channel, observer, FastConfig()); + REQUIRE(machine.Start().has_value()); + CHECK(machine.Parameters().mtu == 1400); + } + + TEST_CASE("MillisUntilNextPoll returns a positive value within the keep-alive period") { + MockControlChannel channel; + channel.SetRequestHandler(MakeWellBehavedDevice(kDeviceMac)); + RecordingObserver observer; + auto config = FastConfig(); + config.keepalive_interval_millis = 5'000; + StateMachine machine(channel, observer, config); + REQUIRE(machine.Start().has_value()); + CHECK(machine.MillisUntilNextPoll() > 0); + CHECK(machine.MillisUntilNextPoll() <= 5'000); + } } // TEST_SUITE("rndis.state_machine") From 37407bf4ceeca9895c73f79c79d75a3c6881ba36 Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Wed, 11 Nov 2026 01:37:20 +0530 Subject: [PATCH 6/6] fix(i18n): correct stale counts and leftover Chinese references --- AGENTS.md | 4 ++-- README.md | 2 +- docs/assets/screenshot-main-zh.jpg | Bin 152595 -> 0 bytes gui/Resources/App-Info.plist | 5 ++--- gui/Scripts/build-gui.sh | 2 +- .../TetherKitApp/Views/UiSelfCapture.swift | 4 ++-- gui/Sources/TetherKitIPC/Localization.swift | 4 ++-- .../TetherKitIPC/LocalizedStrings.swift | 4 ++-- include/tetherkit/common/error.h | 4 ++-- 9 files changed, 14 insertions(+), 15 deletions(-) delete mode 100644 docs/assets/screenshot-main-zh.jpg diff --git a/AGENTS.md b/AGENTS.md index 9023310..733773a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -259,10 +259,10 @@ Legend: `Done` `In progress` `Not started` | 53 | `docs: correct row 50's event_name provenance to tests.yml` | Done | Unanimous round-three reviewer finding, applied verbatim after re-verification at the source: row 50 credited tests/build with gaining event_name in f0fbc1d, but that commit touched release.yml and tests.yml only, and build.yml has never carried a concurrency block at all (pickaxe for event_name and for concurrency: both empty; it is dispatch-only with nothing to cancel). Row 50 now attributes the earlier scoping to tests.yml alone; the lint.yml workflow change itself was always correct and needed no second look. Non-blocking doc-only fix | | 54 | `fix(ci): force arm64 target architecture in codeql build` | Done | Diagnosed CodeQL run 42513807606 (2026-11-08): job failed with linker error "Undefined symbols for architecture x86_64" and "found architecture 'arm64', required architecture 'x86_64'" against Homebrew's /opt/homebrew/Cellar/libusb/1.0.30/lib/libusb-1.0.dylib. CodeQL's macOS tracer forces traced child processes onto the x86_64 slice under Rosetta; without an explicit target architecture, AppleClang emits x86_64 objects that cannot link against arm64-only Homebrew dependencies. `codeql.yml` pins `CMAKE_OSX_ARCHITECTURES=arm64` so clang cross-emits arm64 regardless of driver process architecture; `arch -arm64` cannot be used instead because child builds forced native bypass CodeQL tracing. Hardened `codeql.yml` with a `pull_request` trigger scoped to `.github/**` so workflow edits self-verify. Verified green by two follow-up runs on the branch -- 42578919426 (pull_request event) and 42578945162 (workflow_dispatch; 11m59s C++-analysis job span, 12m5s run wall, Build and analyze clean against arm64 Homebrew libusb) -- sealing the failure-plus-verification pairing exactly like rows 39/41/42/47. | | 55 | `fix(ci): narrow codeql pr trigger, event-scope concurrency` | Done | Review round on row 54, five findings, four applied in codeql.yml plus this row's amendment: (1, consensus) pull_request paths narrowed `.github/**` -> `.github/workflows/**` - dependabot.yml and .github/scripts/ edits never reach the analyzer, while Dependabot's weekly grouped action-bump PR still matches by construction (it edits workflow files) and the header comment now states that ride-along as deliberate; (2, sharpest catch) the header's "No concurrency block: canceling would throw away the whole analysis" sentence predated the PR trigger and no longer held - a superseded PR commit's SARIF is simply replaced by the newer run - so an event-scoped concurrency group mirroring tests.yml landed (group `${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}`, cancel-in-progress: true) with a comment explaining why cancellation is safe within one event and why event_name in the key keeps a push to main from canceling the Monday cron; (3) row 54 amended (above) to cite verification runs 42578919426 and 42578945162, sealing the failure-plus-verification pairing the ledger's audit value depends on; (4, nit) the Build step comment now carries the hard-coded-on-purpose guard - under the tracer `uname -m` reports x86_64, so a `-DCMAKE_OSX_ARCHITECTURES=$(uname -m)` "simplification" would silently reintroduce the bug (the deliberate-choices-need-a-magnitude rule from pitfall 16); (5, INFO) the first-ever green analysis surfaced one pre-existing `cpp/use-after-free` at vendored third_party/doctest/doctest.h:3710 - resolved as a documented decline rather than config: `paths-ignore` is verifiably inert for C/C++ manual builds (codeql team statement in github/codeql-action#3890; GitHub docs confine paths filtering to build-mode none; the traced run would warn "path-based filtering not supported"), and SARIF post-filtering via advanced-security/filter-sarif means a new third-party action for exactly one benign vendored alert - the workflow header records the accepted baseline instead; the fork-PR-403 finding was retracted by the reviewers against current GitHub docs (SARIF upload on pull_request events is permitted under read-only fork tokens). Two disjoint antigravity/gemini-3.8-flash delegates applied the codeql.yml and AGENTS.md edits byte-exact within disjoint file scopes. Verification: the PR-triggered analysis run 42583242299 is green on 75634ad, and the new group's cancel-in-progress proved itself on this very push - the in-flight run 42582860934 for the superseded commit 84d8151 was canceled and replaced by the 75634ad run. | -| 56 | `feat(i18n)!: drop the Chinese column, keep the single-language wiring` (this branch) | Done | Maintainer decision: English-only, but preserving the multi-language plumbing so adding a language stays a bounded five-touch change (checklist now lives at the top of messages.def). messages.def went 843 -> 529 lines via a string-aware perl splice (TETHERKIT_MESSAGE(id, zh, en) -> (id, en)), 310 records; guards on kept column: no Han, no full-width punctuation. i18n.h/i18n.cc rewritten in English: Language enum reduced to kEnglish=0, single kMessageTable, TextIn kept as the designed per-language dispatch point, ParseLanguage no longer accepts zh/Chinese (returns false, CLI rejects with new English message), LanguageFromLocaleString parked at "always English" with the prefix rule to return there, ContextSeparator fixed to ": ". C ABI signature-stable: TK_LANGUAGE_CHINESE removed; ToLanguage returns nullopt for unknown values so a caller still sending the old Chinese value 1 is ignored and stays in English; helper XPC setLanguage method and protocolRevision 3 untouched (still applied, inert). Four ScopedLanguage guard sites flipped kChinese->kEnglish with English expectations (bridge stats "queue depth", CONDIS reject "connection-oriented", keepalive fatal "keep-alive", MalformedReason "none"); test_net_link's sysctl-why bilingual loop collapsed to the single table. test_common_i18n.cc rewritten: per-string placeholder well-formedness, no mixed indexing, contiguous indices 0..n-1; the cross-language parity checks were retired and are documented to come back when a second language lands. CLI help no longer advertises zh. Verification: cmake -Werror build clean, ctest 23/23 suites pass, smokes green (--version; --help shows the reworded --lang lines; --lang zh --list errors in English; --lang en --list and LC_ALL=zh_CN --list render English). GUI side deliberately untouched this phase, so `swift test` still exercises bilingual Swift tables and must be green throughout; the Swift strip is the next commit. Splice/validation scripts live in the session temp dir (not in the repo). Untracked docs/icon_resources/ and draft_icons/ predate this work and were left alone. | +| 56 | `feat(i18n)!: drop the Chinese column, keep the single-language wiring` (this branch) | Done | Maintainer decision: English-only, but preserving the multi-language plumbing so adding a language stays a bounded five-touch change (checklist now lives at the top of messages.def). messages.def went 843 -> 548 lines via a string-aware perl splice (TETHERKIT_MESSAGE(id, zh, en) -> (id, en)), 310 records; guards on kept column: no Han, no full-width punctuation. i18n.h/i18n.cc rewritten in English: Language enum reduced to kEnglish=0, single kMessageTable, TextIn kept as the designed per-language dispatch point, ParseLanguage no longer accepts zh/Chinese (returns false, CLI rejects with new English message), LanguageFromLocaleString parked at "always English" with the prefix rule to return there, ContextSeparator fixed to ": ". C ABI signature-stable: TK_LANGUAGE_CHINESE removed; ToLanguage returns nullopt for unknown values so a caller still sending the old Chinese value 1 is ignored and stays in English; helper XPC setLanguage method and protocolRevision 3 untouched (still applied, inert). Four ScopedLanguage guard sites flipped kChinese->kEnglish with English expectations (bridge stats "queue depth", CONDIS reject "connection-oriented", keepalive fatal "keep-alive", MalformedReason "none"); test_net_link's sysctl-why bilingual loop collapsed to the single table. test_common_i18n.cc rewritten: per-string placeholder well-formedness, no mixed indexing, contiguous indices 0..n-1; the cross-language parity checks were retired and are documented to come back when a second language lands. CLI help no longer advertises zh. Verification: cmake -Werror build clean, ctest 23/23 suites pass, smokes green (--version; --help shows the reworded --lang lines; --lang zh --list errors in English; --lang en --list and LC_ALL=zh_CN --list render English). GUI side deliberately untouched this phase, so `swift test` still exercises bilingual Swift tables and must be green throughout; the Swift strip is the next commit. Splice/validation scripts live in the session temp dir (not in the repo). Untracked docs/icon_resources/ and draft_icons/ predate this work and were left alone. | | 57 | `feat(i18n)!: strip the Swift side to English-only, keep the add-a-language wiring` (this branch) | Done | Phase 2 complement to row 56. LocalizedStrings.swift went 743 -> 685 lines via a string-aware perl splice: every `localizations` switch row lost its Chinese first argument, with two guards - the dropped arg must contain Han or full-width punctuation OR be identical to the kept one (the languageEnglish native-name row needs the second rule), and the kept column must be Han- and fullwidth-free; kept spans are preserved byte-exact, including multi-line `+`-concatenation layouts (installer/update/permission strings); the file header and 42 MARK/comment lines went to English via an exact-line map; the `languageChinese` key is gone entirely (both Pickers hard-code `Text(verbatim: "English")` under the tag, so the key had no non-test consumers). Language enum is `.english` only with `cValue` constant 0; LanguagePreference loses `.chinese`, so a stale saved rawValue "chinese" decodes to nil and falls back to `.system` (graceful, symmetric with the C ABI ignoring unknown values); `L10n.apply` and `text(_:in:)` survive as the add-a-language dispatch points; new internal `L10n.localeLanguage` is parked always-English, exactly mirroring LanguageFromLocaleString on the C++ side, and `systemLanguage` still reads Locale.preferredLanguages through it; HelperService.setLanguage keeps its ignore-unknown semantics (an old App still sending "chinese" leaves the helper in English); both language menus now offer Follow system + English. **Swift constraint determined empirically** (tiny probe program): single-element labeled tuples are illegal - `cannot create a single-element tuple with an element label` - so the planned `(english: String)` one-member tuple is unrepresentable and rows are bare strings; when a language is added the property becomes a real labeled tuple and every case appends one labeled argument, the same way C++ rows `(id, en)` grow. LocalizationTests rewritten for the single table, still 7 cases: per-key placeholder contiguity, the %ld-integer rule, language resolution incl. localeLanguage, and formatting substitution; the cross-language placeholder/parity checks retire until a second language and the suite header says they return then. Verification: swift build green (3.3s, only the pre-existing libtetherkit dylib-version warning); swift test 30/30 - TetherKitIPCTests 17/17 (Authorization 2 + HelperConstants 8 + the 7 rewritten LocalizationTests) and TetherKitUIModelTests 13/13; the current-state snapshot below says "17 IPC + 7 UIModel = 24", which predates the sidebar test additions, not this work. Also folded in here: the row-56 typo fix (i18n.cci -> i18n.h). Splice/unwrap scripts live in the session temp dir. | | 58 | `feat(i18n)!: translate every Chinese comment and user-facing benchmark string to English` (this branch) | Done | P3 = the comment-and-doc-string half of the English-only migration (P1 = C++ message table, P2 = Swift string table). Scope: ~100 files, every Han comment in `include/`, `src/`, `tests/`, `benchmarks/`, `gui/` plus the benchmark tool's own report output; `docs/` + `README` + `AGENTS.md` are P4. Work was delegated in disjoint batches through the T3 `delegate_task` tool on the three OpenCode models the maintainer named (iceman-free, minimax-m3.1-flash-free, mimo-v2.6-flash-free; MiMo hit a free-tier limit mid-run and its remainder was re-dispatched to MiniMax); every batch was verified before acceptance, and three were sent back or fixed by hand. Verification method, all in-session: a comment-only scope proof (comments stripped from HEAD and from the tree, then the code token streams compared -- byte-identical for every file except the deliberately allowed literals), a comment-line-count drop check (a translation must not shrink a file's prose), a repo-wide Han census, the CI lint gate reproduced locally (vendored `.github/scripts/clang-format-diff.py` over the changed hunks, base HEAD), a full `-Werror` build plus 23/23 ctest, and `swift build`/`swift test` for the GUI batches. Findings worth keeping: (1) the house style is **pure ASCII in comments** -- the earlier crew had already converted box-drawing to `+ - |` in `feth_device.h` and dropped the `star`/`warning` markers, so two batches that kept them were normalized afterwards (emphasis now lives in the prose); (2) the CI format gate is **line-scoped**, so touching a test title drags its whole `TEST_SUITE` block into scope and clang-format then wants it re-indented by 2 -- the Chinese-era test files had their cases at column 0 and were never format-clean; the reformat was applied with an invariant that the **code token stream** is unchanged (a re-indent or a re-wrap may move a line break, never a token) and that the **comment word stream** is unchanged (a long comment may be reflowed, never reworded); (3) `src/app/main.cc` had to have its include blocks reordered (project headers before system headers, per `.clang-format`) -- pre-existing baseline debt that only became visible once a comment above it changed; (4) `docs/BENCHMARKS.md` is **half generated**: its first part is the literal output of `tetherkit_bench`, so the tool's report strings had to be translated too (they are user-facing) and the document's generated part must use exactly the wording the tool now prints -- but the document must NOT be regenerated, because that would drop the root-measured feth/BPF numbers and the hand-written analysis; (5) the `device` fixture in `tests/test_capi.cc` was **swapped, not translated**: it is a byte-pattern fixture (two 3-byte UTF-8 characters) and became two U+20AC characters (the euro sign, 3 bytes each, 6 bytes total) with every byte count in the assertions preserved; (6) the two CI grep anchors in `tests/test_net_link.cc` (the `ReportSkip` format string and the `created {} <-> {}` message) are byte-exact and must stay so; (7) a *passing* test is not proof -- a log-text needle such as `find("libusb initialized")` must match the production message table exactly or the assertion passes vacuously; (8) the Chinese strings in `tests/test_common_error.cc` are **paired** (a context string and the assertion on it) and were translated on both sides with identical wording, which is why the brief for that batch had to be corrected before dispatch. The GUI language plumbing, the XPC protocol revision 3 and the C ABI are untouched by this phase | -| 59 | `docs(i18n)!: translate the documentation to English and drop the Chinese README` (this branch) | Done | P4 = the documentation half of the English-only migration (P3 = code comments). Scope: `AGENTS.md` (857 -> 487 lines), the seven `docs/*.md` files, `README.md`, and the deletion of `README.zh-CN.md`. Delegated in four disjoint batches through the T3 `delegate_task` tool on the three OpenCode models the maintainer named; the README edits and the Chinese-README deletion were done by the maintainer. Verification, per file: a numeric-token multiset comparison against HEAD (every measurement preserved; the only differences are three correct `wan`-unit conversions in BENCHMARKS.md -- `124 wan` -> "1.24 million frames per second", `2.7 wan` -> "27,000 frames per second", `46 wan` -> "460,000 frames per second"), a fence-aware structural comparison (headings, table row counts and per-table pipe counts identical in every file), a link-resolution check, and a repo-wide Han scan that reaches zero. Findings worth keeping: (1) `docs/BENCHMARKS.md` is **half generated** -- its first part is the literal output of `tetherkit_bench`, so it must use exactly the wording the tool now prints, and it must **not** be regenerated (that would drop the root-measured feth/BPF numbers and the hand-written analysis); the tool's English output was captured and used as the reference; (2) a document that quotes GUI strings must quote them **verbatim**: four quotes in GUI-ARCHITECTURE.md did not match the table ("Check for Updates..." vs the real "Check for Updates...", "Uninstall the privileged helper..." vs "...helper?", the invented "Install / update the privileged helper" button, and a `%ld` template rendered as a literal) and were corrected against `LocalizedStrings.swift`; (3) **ASCII-only yields to fidelity** when the text is a quotation of a real string or a literal that carries meaning: GUI-SPIKE.md keeps a full-width parenthesis in its bash snippet because the documented `0xEF` lead-byte trap depends on it (the reproduction was re-verified: `IFACE?: unbound variable`), and the two UI titles above keep the app's real ellipsis and question mark; (4) the AGENTS.md ledger rows and the pitfall list keep their exact numbering (the 58 pre-existing rows, plus this one; pitfalls 1..24 plus 4b), so the code's citation "AGENTS.md section 7, pitfalls 12 and 13" still resolves to the same two pitfalls; the emoji status marks became the word "Done" table-wide (row 58 included, its prose untouched), and the stale test count was corrected from 24 to the measured 30 (17 TetherKitIPC + 13 TetherKitUIModel); (5) the AGENTS.md directory tree no longer lists the deleted `README.zh-CN.md`, while ledger row 38 keeps the mention as a historical record of what that commit did; (6) the i18n section was adapted to the single-language reality while naming the retained plumbing and its five-touch checklist. Nothing pushed | +| 59 | `docs(i18n)!: translate the documentation to English and drop the Chinese README` (this branch) | Done | P4 = the documentation half of the English-only migration (P3 = code comments). Scope: `AGENTS.md` (857 -> 488 lines), the six `docs/*.md` files, `README.md`, and the deletion of `README.zh-CN.md`. Delegated in four disjoint batches through the T3 `delegate_task` tool on the three OpenCode models the maintainer named; the README edits and the Chinese-README deletion were done by the maintainer. Verification, per file: a numeric-token multiset comparison against HEAD (every measurement preserved; the only differences are three correct `wan`-unit conversions in BENCHMARKS.md -- `124 wan` -> "1.24 million frames per second", `2.7 wan` -> "27,000 frames per second", `46 wan` -> "460,000 frames per second"), a fence-aware structural comparison (headings, table row counts and per-table pipe counts identical in every file), a link-resolution check, and a repo-wide Han scan that reaches zero. Findings worth keeping: (1) `docs/BENCHMARKS.md` is **half generated** -- its first part is the literal output of `tetherkit_bench`, so it must use exactly the wording the tool now prints, and it must **not** be regenerated (that would drop the root-measured feth/BPF numbers and the hand-written analysis); the tool's English output was captured and used as the reference; (2) a document that quotes GUI strings must quote them **verbatim**: four quotes in GUI-ARCHITECTURE.md did not match the table ("Check for Updates..." vs the real "Check for Updates...", "Uninstall the privileged helper..." vs "...helper?", the invented "Install / update the privileged helper" button, and a `%ld` template rendered as a literal) and were corrected against `LocalizedStrings.swift`; (3) **ASCII-only yields to fidelity** when the text is a quotation of a real string or a literal that carries meaning: GUI-SPIKE.md keeps a full-width parenthesis in its bash snippet because the documented `0xEF` lead-byte trap depends on it (the reproduction was re-verified: `IFACE?: unbound variable`), and the two UI titles above keep the app's real ellipsis and question mark; (4) the AGENTS.md ledger rows and the pitfall list keep their exact numbering (the 58 pre-existing rows, plus this one; pitfalls 1..24 plus 4b), so the code's citation "AGENTS.md section 7, pitfalls 12 and 13" still resolves to the same two pitfalls; the emoji status marks became the word "Done" table-wide (row 58 included, its prose untouched), and the stale test count was corrected from 24 to the measured 30 (17 TetherKitIPC + 13 TetherKitUIModel); (5) the AGENTS.md directory tree no longer lists the deleted `README.zh-CN.md`, while ledger row 38 keeps the mention as a historical record of what that commit did; (6) the i18n section was adapted to the single-language reality while naming the retained plumbing and its five-touch checklist. Nothing pushed | ### Current state diff --git a/README.md b/README.md index a24a680..31f55ad 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ connect, configure IP in three clicks — plus a command-line tool, `tetherkit-c writing raw Ethernet frames directly. - **No kernel code**: pure user-space C++23. No kext, no DriverKit, no need to disable SIP. -> ⚠️ **Status**: all modules are implemented; 210 test cases (10,408 assertions) pass +> ⚠️ **Status**: all modules are implemented; 210 test cases (10,391 assertions) pass > under both a normal build and a ThreadSanitizer build. End-to-end throughput, > measured against a real Android phone: **~326 Mbps RX / ~240–300 Mbps TX, zero > retransmits in both directions** (USB 2.0 high-speed; theoretical ceiling 426 Mbps). diff --git a/docs/assets/screenshot-main-zh.jpg b/docs/assets/screenshot-main-zh.jpg deleted file mode 100644 index 61a11eb55cf152779db15e3e28efd4d032de6954..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 152595 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 CFBundleDevelopmentRegion @@ -46,7 +46,6 @@ CFBundleLocalizations en - zh-Hans