cmux-architecture
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
Chinesecmux Architecture
cmux 架构
Package architecture
包架构
We are migrating cmux from a single app target into Swift Packages under . Every new package must satisfy three rules:
Packages/- Ergonomic. Public API surface matches what callers naturally want to write. Default to internal access; expose only for types and functions that downstream consumers actually use. Avoid friction such as forcing every call site through a builder or wrapper when a direct API is fine.
public - No dependency cycles. Packages form a strict DAG. A package may only depend on packages strictly lower in the graph. When two packages need to share a type, lift it to a common lower-level package or define a protocol seam in the consumer. Every new dependency edge requires re-checking that the graph stays acyclic.
- Clear but not overly narrow responsibilities. A package owns one full domain (e.g. settings, appearance, workspace, terminal, browser, command palette), not a slice of one. A package called "appearance math" or "workspace model" is too narrow — it forces every consumer that touches the surrounding domain to also depend on the sibling slices. Prefer a single that owns settings, theming, colors, glass, and snapshots together, over
CmuxAppearance+CmuxAppearanceMath+CmuxAppearanceTheme. Don't fragment a domain intoCmuxAppearanceSettings+CmuxFooFormatting+CmuxFooLogic— that's folder structure inside a single package, not module structure. A package boundary exists because more than one consumer needs the contents, or a build/test seam needs to exist.CmuxFooState
When in doubt, extract leaf-first: pull out the package that has no internal dependencies. Consumers in the app target stay put and only update imports. Each leaf shrinks the app target without requiring downstream packages to exist yet.
The existing packages under predate this policy and should not be used as design references.
Packages/Wiring a new local package into the project. lists package dependencies explicitly (it is not a synchronized-folder project). Adding means mirroring an existing package's entries — one (in the project's ), one , and a linked in the Frameworks phase of every target that imports it. The app-target packages link into both and (so tests can and inject them); copy a recent leaf like for the exact shape, then run and . A package the app builds against but does not link will compile the app yet fail the test target.
cmux.xcodeprojPackages/CmuxFooproject.pbxprojXCLocalSwiftPackageReferencepackageReferencesXCSwiftPackageProductDependencyPBXBuildFilecmuxcmux-unitimportCmuxSocketControlscripts/normalize-pbxproj.pyscripts/check-pbxproj.shcmux-unit我们正将cmux从单一应用目标迁移至目录下的Swift Packages。每个新包必须满足以下三条规则:
Packages/- 易用性:公共API的设计符合调用者的自然使用习惯。默认使用内部访问权限;仅对下游消费者实际使用的类型和函数暴露权限。避免不必要的使用门槛,比如在直接API可行的情况下,强制所有调用点通过构建器或包装器进行调用。
public - 无依赖循环:包之间形成严格的有向无环图(DAG)。一个包只能依赖图中层级更低的包。当两个包需要共享某个类型时,将其提升至共同的底层包,或在消费者中定义协议接口。每新增一条依赖关系,都需要重新检查确保图的无环性。
- 职责清晰不过于细分:一个包负责完整的业务域(例如:设置、外观、工作区、终端、浏览器、命令面板),而非业务域的某个切片。类似“外观计算”或“工作区模型”的包职责过于狭窄——这会迫使所有涉及相关业务域的消费者同时依赖多个兄弟切片。优先选择单一的包,统一管理设置、主题、颜色、玻璃效果和快照,而非拆分为
CmuxAppearance+CmuxAppearanceMath+CmuxAppearanceTheme。不要将业务域拆分为CmuxAppearanceSettings+CmuxFooFormatting+CmuxFooLogic——这属于单个包内的文件夹结构,而非模块结构。包边界的存在是因为有多个消费者需要其内容,或是需要构建/测试隔离层。CmuxFooState
如有疑问,优先提取叶子包:先提取无内部依赖的包。应用目标中的消费者保持不变,仅更新导入语句。每个叶子包的提取都会缩小应用目标的规模,且无需下游包提前存在。
Packages/将新的本地包接入项目:显式列出包依赖(它不是同步文件夹项目)。添加意味着需要镜像现有包的条目——一个(位于项目的中)、一个,以及在所有导入该包的目标的Frameworks阶段中添加一个链接。应用目标的包需要同时链接到和(以便测试可以并注入它们);复制最近的叶子包(如)的配置结构,然后运行和。如果应用构建依赖某个包,但未链接该包,会导致应用编译通过但测试目标失败。
cmux.xcodeprojPackages/CmuxFooproject.pbxprojXCLocalSwiftPackageReferencepackageReferencesXCSwiftPackageProductDependencyPBXBuildFilecmuxcmux-unitimportCmuxSocketControlscripts/normalize-pbxproj.pyscripts/check-pbxproj.shcmux-unitRefactor architecture: layers, Coordinator/Service/Repository, dependency inversion
重构架构:分层、Coordinator/Service/Repository、依赖倒置
These higher-level patterns are binding on every new or moved/meaningfully-rewritten file. (The full blueprint, with worked examples and the per-god decomposition, lives in the cmuxterm-hq control repo under ; the enforceable core is below.)
docs/cmux-refactor-audit/blueprint/Layered, downward-only DAG. Packages form a strict acyclic graph in five layers; dependencies point only downward:
- Core (e.g. ) — pure
CmuxCorevalues, IDs, DTOs, errors, and the protocol seams shared across domains. No AppKit/SwiftUI/I/O. The lift target when two domains need the same type.Sendable - Services / infrastructure — s implementing core protocols against the outside world (process/PTY, filesystem, sockets, web API, notifications, auth). One package per cohesive capability.
actor - Domain / state — models + Coordinators, one package per feature domain; owns that domain's mutable state.
@MainActor @Observableis the exemplar.CmuxSettings - UI — SwiftUI/AppKit views, one UI package per domain package, depending only on its domain package + Core, never on a Service directly. is the exemplar.
CmuxSettingsUI - Executable (/
cmuxApp) — a thin composition shim, no business logic.AppDelegate
Classify every extracted entity by intent:
- Coordinator — a orchestrator that sequences a user flow and owns navigation/selection/lifecycle state, calling Services and child models. Does no I/O itself.
@MainActor @Observable - Service — an (or
actoronly when an AppKit main-thread API forces it) performing one outside-world capability; exposes@MainActor/async+await, holds only its own resource handles, holds no UI state.AsyncStream - Repository — an mediating one persistence source of truth (file, defaults, web API) behind CRUD-shaped async methods returning value types. Precedents:
actor,JSONConfigStore.UserDefaultsSettingsStore
Dependency inversion. Lower packages publish protocols; concrete Services/Repositories conform; higher layers depend on , never the concrete type. Share a type by lifting it to Core or defining a protocol seam in the consumer — never a stored property reaching across modules. Injection is constructor () injection only: no global container, no singleton, no . The executable app target is the single composition root — the one place concretes are named and the object graph is assembled. SwiftUI may carry already-constructed models down a view tree (as does), but is never the source of truth for service wiring.
any Protocolinitstatic let sharedEnvironment@ObservableSettingsRuntimeState + SwiftUI wiring. Domain state lives in models (never /). A god model decomposes into cohesive child sub-models owned by their domain packages and composed by the home object via held references; cross-domain reads go behind read-only protocols. In views use (owned), / plain (passed-in), or + (injected) — never / / / .
@MainActor @ObservableObservableObject@Published@Observable@State@Bindablelet@Environment(M.self).environment(...)@StateObject@ObservedObject@EnvironmentObject.environmentObject(_:)Executable-target boundary (three hard constraints — invert, never work around):
@mainandcmuxAppstay in the executable target as the thin composition shim; that residual is the intended end state, not debt.AppDelegate- A type is declared in exactly one module and a lower package cannot extend a higher-owned type, so /
AppDelegate+*/cmuxApp+*extensions do not move down: extract the behavior into a Coordinator/Service/Repository, have the god object own an instance, and reduce the extension to a one-line forward.Workspace+* - Stored properties cannot cross module boundaries: decompose god-model state into child sub-models owned by domain packages, composed by held reference, with cross-cutting reads behind read-only protocols.
@Observable
这些高层级模式适用于所有新增或迁移/大幅重写的文件。(完整蓝图包含实例和详细分解,位于cmuxterm-hq控制仓库的目录下;以下是强制执行的核心内容。)
docs/cmux-refactor-audit/blueprint/分层、单向依赖的DAG:包分为五层,形成严格的无环图;依赖仅指向更低层级:
- 核心层(例如)——纯
CmuxCore值、ID、DTO、错误,以及跨业务域共享的协议接口。不依赖AppKit/SwiftUI/I/O。当两个业务域需要同一类型时,将其提升至核心层。Sendable - 服务/基础设施层——实现核心协议的,对接外部资源(进程/PTY、文件系统、套接字、Web API、通知、认证)。每个包对应一个内聚的功能。
actor - 业务域/状态层——模型 + Coordinator,每个包对应一个功能业务域;负责该业务域的可变状态。
@MainActor @Observable是典型示例。CmuxSettings - UI层——SwiftUI/AppKit视图,每个UI包对应一个业务域包,仅依赖其对应的业务域包 + 核心层,绝不直接依赖服务层。是典型示例。
CmuxSettingsUI - 可执行层(/
cmuxApp)——薄的组合层,无业务逻辑。AppDelegate
按意图对每个提取的实体进行分类:
- Coordinator——的编排器,负责编排用户流程,管理导航/选择/生命周期状态,调用服务和子模型。自身不执行I/O操作。
@MainActor @Observable - Service——(或仅在AppKit主线程API强制要求时使用
actor),执行单一外部资源操作;暴露@MainActor/async+await接口,仅持有自身的资源句柄,不持有UI状态。AsyncStream - Repository——,通过CRUD形状的异步方法返回值类型,作为单一持久化数据源的中间层。参考示例:
actor、JSONConfigStore。UserDefaultsSettingsStore
依赖倒置:低层包发布协议;具体的Service/Repository实现协议;高层包依赖,而非具体类型。通过将类型提升至核心层或在消费者中定义协议接口来共享类型——绝不通过跨模块的存储属性实现。仅使用构造函数()注入:无全局容器、无单例、无。可执行应用目标是唯一的组合根——在此处指定具体类型并组装对象图。SwiftUI 可将已构造的模型传递给视图树(如的做法),但绝不是服务 wiring 的数据源。
any Protocolinitstatic let sharedEnvironment@ObservableSettingsRuntime状态 + SwiftUI wiring:业务域状态存储在模型中(绝不使用/)。上帝模型会分解为内聚的子模型,由对应的业务域包拥有,并通过引用组合到主对象中;跨业务域的读取通过只读协议实现。在视图中使用(视图自有)、 / 普通(传入),或 + (注入)——绝不使用 / / / 。
@MainActor @ObservableObservableObject@Published@Observable@State@Bindablelet@Environment(M.self).environment(...)@StateObject@ObservedObject@EnvironmentObject.environmentObject(_:)可执行目标边界(三个硬性约束——遵守而非规避):
@main和cmuxApp保留在可执行目标中作为薄组合层;这是预期的最终状态,而非技术债务。AppDelegate- 类型仅在一个模块中声明,低层包不能扩展高层包的类型,因此/
AppDelegate+*/cmuxApp+*扩展不能下移:将行为提取到Coordinator/Service/Repository中,让上帝对象持有其实例,并将扩展简化为一行转发代码。Workspace+* - 存储属性不能跨模块边界:将上帝模型的状态分解为子模型,由业务域包拥有,通过引用组合,跨域读取通过只读协议实现。
@Observable
File organization
文件组织
One major type per file. Each , , , , or that is part of a public API (or has any meaningful body) lives in its own file named after the type (, , — not one shared ). This rule applies to all new code in and to any new files added to the app target.
structclassenumactorprotocolControl.swiftLabeledChoice.swiftListControl.swiftSettingControl.swiftPackages/- Small, closely-bound helpers (, nested types, single-line extensions used only inside the file) can stay with the parent type. Anything bigger or independently meaningful gets its own file.
private struct - Conformance-adding extensions for a type defined elsewhere go in or
TypeName+Conformance.swift, not bundled into the consuming feature file.TypeName+Feature.swift - Type-erased wrappers () live next to the type they erase (
AnyFooandFoo.swift), each in its own file.AnyFoo.swift - Existing god files (,
ContentView.swift,Workspace.swift,TabManager.swift) are the pattern this rule exists to stop. When migrating code out of them, split into one file per type even if it triples the file count. File count is cheap; "find this type" being unanswerable is expensive.cmuxApp.swift
每个主要类型对应一个文件。每个属于公共API的、、、或(或任何有意义实现体的类型)都存放在以该类型命名的文件中(例如、、——而非共享的)。此规则适用于中的所有新代码,以及添加到应用目标的所有新文件。
structclassenumactorprotocolControl.swiftLabeledChoice.swiftListControl.swiftSettingControl.swiftPackages/- 小型、紧密关联的辅助类型(、嵌套类型、仅在文件内部使用的单行扩展)可与父类型放在同一文件中。任何较大或独立有意义的类型都应单独放在一个文件中。
private struct - 为其他地方定义的类型添加一致性的扩展,应放在或
TypeName+Conformance.swift中,而非捆绑到消费功能的文件中。TypeName+Feature.swift - 类型擦除包装器()与被擦除的类型放在一起(
AnyFoo和Foo.swift),各占一个文件。AnyFoo.swift - 现有的上帝文件(、
ContentView.swift、Workspace.swift、TabManager.swift)是本规则旨在杜绝的模式。从这些文件迁移代码时,即使文件数量增加三倍,也要按每个类型一个文件拆分。文件数量的成本很低;而“找不到某个类型”的成本很高。cmuxApp.swift
Documentation
文档
Every symbol in any new Swift package under is documented with a Swift-DocC triple-slash comment at the time of writing. Treat docs as part of the API surface, not as follow-up work.
publicPackages/- Format. Use doc comments above the symbol. First line is a one-sentence summary that fits on a single line and ends with a period. If more context is needed, leave a blank
///line, then add a discussion paragraph. Use////- Parameter name:/- Returns:callouts on- Throws:andinitsymbols that take parameters or throw. Use Markdown freely (bold, fenced code blocks for examples, backticks for inline code).func - Cross-references. Refer to other symbols using double-backticks: CmuxSetting. Plain backticks are for non-symbol code (,
UserDefaults.standard).@AppStorage - What to document on each symbol. Types: what they represent and when to use them. Enums: meaning of each case. Init parameters: especially defaults and the reason for them. Properties: what value they hold and any invariants. Methods: what they do, plus parameters/returns/throws. Generic constraints: which /
Valueshapes the type accepts and why (e.g.,Element).Sendable & Codable - Examples. Non-trivial APIs get at least one example in a fenced block, ideally a real declaration from this codebase. Keep examples short and idiomatic.
```swift - Internal vs public. and
internalsymbols get a one-lineprivatewhen the intent is non-obvious; verbosity is not required at that scope. The public boundary is the one that needs full coverage./// - No stale docs. When you change a symbol's behavior or signature, update its doc comment in the same edit. Docs that describe last week's behavior are worse than no docs.
- Don't comment-narrate the body. Doc comments describe the contract from the outside. Inline comments inside method bodies are reserved for non-obvious why, not what (the existing rule from the top-level guidance still applies).
//
This rule applies to all packages under . Code in the main app target is not retroactively required to be documented, but new symbols added to packages must be.
Packages/publicPackages/public- 格式:在符号上方使用文档注释。第一行是一句简短的总结,单行显示并以句号结尾。如果需要更多上下文,留一行空的
///,然后添加讨论段落。对于带有参数或抛出异常的///和init符号,使用func/- Parameter name:/- Returns:标注。自由使用Markdown(粗体、示例代码块、行内代码反引号)。- Throws: - 交叉引用:使用双反引号引用其他符号:CmuxSetting。单反引号用于非符号代码(、
UserDefaults.standard)。@AppStorage - 每个符号的文档内容:类型:代表什么以及何时使用。枚举:每个case的含义。初始化参数:尤其是默认值及其原因。属性:存储的值以及任何不变量。方法:功能,加上参数/返回值/抛出异常说明。泛型约束:类型接受的/
Value形状及原因(例如Element)。Sendable & Codable - 示例:非平凡的API至少需要一个代码块示例,理想情况下是本代码库中的真实声明。示例应简短且符合惯用写法。
```swift - 内部与公共符号:和
internal符号在意图不明显时添加一行private注释;此范围不需要冗长的文档。公共边界需要完整的文档覆盖。/// - 无过时文档:当修改符号的行为或签名时,在同一编辑中更新其文档注释。描述上周行为的文档比没有文档更糟糕。
- 不要注释描述实现体:文档注释从外部描述契约。方法体内部的注释仅用于解释非显而易见的原因,而非内容(顶层指南中的现有规则仍然适用)。
//
此规则适用于下的所有包。主应用目标中的代码不要求追溯添加文档,但添加到包中的新符号必须遵循此规则。
Packages/publicPackage design discipline
包设计规范
These are the recurring design mistakes that have to be caught at the design step, not at code review:
- No shared-singleton accessors. /
static let standard/sharedon a package type that holds runtime state is a singleton-by-another-name. Construct the package type at the app's startup site and inject it.defaultis fine for declarations — identifiers, schema entries, enum cases — but not for behavior.static let - No namespace-enums. (a no-case enum used as a namespace) is a fake namespace that fights the rest of the design (no instances, no DI, no test seam). Prefer a value-typed struct passed via constructor when the helper might gain configuration, or a file-scope
enum Foo { static func bar() }for pure helpers internal to one file.private func - No parallel hand-maintained registries. When a list mirrors a set of declared items (e.g. mirroring the catalog's stored properties), derive the list via
catalog.allreflection or a macro. Two sources of truth drift silently; the IDE doesn't tell you.Mirror - Prefer compile-time invariants to runtime traps. If the pattern is for a "programmer error" case, encode it in the type system (phantom types, separate concrete flavors). Runtime traps become silent fallbacks in release builds.
guard ... else { assertionFailure(...); return default } - No free functions. Functionality is always scoped to an entity that owns the responsibility: a method on a value type, an extension on the type the operation belongs to, or a member of the Coordinator/Service/Repository that uses it. Top-level declarations (any visibility, including file-scope
func) are banned. The only sanctioned exception is aprivate functrampoline a C API forces on us, marked with a one-line justification.@convention(c) - Nested types still count for the one-major-type-per-file rule. A inside
private final class WatcherAttachmentis a major type. Move it to its own file the moment it has a meaningful body.JSONConfigFileWatcher.swift
以下是需要在设计阶段就发现的常见设计错误,而非等到代码评审时:
- 无共享单例访问器:包类型上的/
static let standard/shared持有运行时状态,本质上是另一种形式的单例。在应用启动时构造包类型并注入。default适用于声明——标识符、 schema 条目、枚举case——但不适用于行为。static let - 无命名空间枚举:(无case的枚举用作命名空间)是伪命名空间,与其他设计冲突(无实例、无依赖注入、无测试隔离层)。当辅助功能可能需要配置时,优先选择通过构造函数传递的值类型结构体;对于单个文件内部的纯辅助功能,使用文件作用域的
enum Foo { static func bar() }。private func - 无并行手动维护的注册表:当列表镜像一组已声明的项时(例如镜像目录的存储属性),通过
catalog.all反射或宏生成列表。两个数据源会悄然偏离;IDE不会提醒你。Mirror - 优先使用编译期不变量而非运行时陷阱:如果模式是用于“程序员错误”场景,将其编码到类型系统中(幽灵类型、单独的具体类型)。运行时陷阱在发布构建中会变为静默回退。
guard ... else { assertionFailure(...); return default } - 无自由函数:功能始终归属于负责该职责的实体:值类型的方法、操作所属类型的扩展,或使用该功能的Coordinator/Service/Repository的成员。禁止顶层声明(任何可见性,包括文件作用域的
func)。唯一允许的例外是C API强制要求的private functrampoline,需添加一行理由说明。@convention(c) - 嵌套类型仍需遵守“一个主要类型一个文件”规则:内部的
JSONConfigFileWatcher.swift是主要类型。一旦它有有意义的实现体,就应移至单独的文件。private final class WatcherAttachment
Testability
可测试性
Every public type added to must be testable from a test target without launching the app target, without booting AppKit, and without depending on the user's filesystem or . Production-grade designs surface a test seam at every boundary:
Packages/UserDefaults.standard- No global state in package code. Every public type that needs ,
UserDefaults, an on-disk path, an environment variable, or a clock takes it via initializer parameter. Tests pass aFileManagerscoped to the test, a temp directory URL, a fixedUserDefaults(suiteName:), etc.Date - No reliance on /
.shared. A public type that hardcodes.standardorUserDefaults.standardinside its implementation cannot be tested without polluting the developer's actual settings. Inject these at the seam.FileManager.default - Test through injected seams, never a static test hook. A (or any global mutable "override" a test swaps in) is global state by another name: it leaks across tests, forces
nonisolated(unsafe) static var fooForTesting, and usually needs a lock. Replace it with a protocol seam injected throughnonisolated(unsafe)(e.g.init); the test passes a conforming fake. When you extract such a type into a package, deleting the static hook (and the lock it required) is part of the extraction, not a follow-up.init(commandRunner: any CommandRunning = CommandRunner()) - Public APIs return values, not side effects, where possible. A function that mutates global UserDefaults and returns is harder to test than one that returns the changed value and lets the caller persist. Prefer pure transformations + thin imperative layers.
Void - Asynchronous APIs surface their observation as . Tests can iterate
AsyncStreamdeterministically and assert the sequence of yielded values. AvoidAsyncStream-only patterns where the test has to spin a runloop.NotificationCenter - Document the test pattern alongside any non-trivial public surface. The package's and any DocC catalog should show how to instantiate the type with test-friendly dependencies.
README.md
If a design is hard to test, it is wrong. Reach for the constructor parameter list, not the test bench.
Packages/UserDefaults.standard- 包代码中无全局状态:每个需要、
UserDefaults、磁盘路径、环境变量或时钟的公共类型,都通过初始化参数获取。测试时传入作用域限定为测试的FileManager、临时目录URL、固定UserDefaults(suiteName:)等。Date - 不依赖/
.shared:在实现中硬编码.standard或UserDefaults.standard的公共类型,无法在不污染开发者实际设置的情况下进行测试。在隔离层注入这些依赖。FileManager.default - 通过注入的隔离层测试,而非静态测试钩子:(或任何测试可替换的全局可变“覆盖”)本质上是另一种形式的全局状态:它会在测试间泄漏、需要
nonisolated(unsafe) static var fooForTesting、通常需要锁。替换为通过nonisolated(unsafe)注入的协议隔离层(例如init);测试时传入符合协议的假实现。将此类类型提取到包中时,删除静态钩子(及其所需的锁)是提取工作的一部分,而非后续工作。init(commandRunner: any CommandRunning = CommandRunner()) - 公共API尽可能返回值,而非副作用:修改全局UserDefaults并返回的函数,比返回更改后的值并让调用者持久化的函数更难测试。优先选择纯转换 + 薄命令式层。
Void - 异步API通过暴露观察结果:测试可以确定性地遍历
AsyncStream并断言生成的值序列。避免仅使用AsyncStream的模式,因为测试需要运行循环才能等待事件。NotificationCenter - 记录测试模式:任何非平凡的公共API都应附带测试模式说明。包的和任何DocC目录应展示如何使用测试友好的依赖实例化类型。
README.md
如果某个设计难以测试,那它就是错误的。优先修改构造函数参数列表,而非测试代码。
Modern Swift concurrency
现代Swift并发
All new code in and any new files added to the app target use Swift 6 concurrency primitives: , /, /, , . Old primitives — locks, manual KVO, , completion handlers, used as a serial lock — are not allowed.
Packages/actorasyncawaitAsyncStreamAsyncSequence@Observable@MainActor@PublishedDispatchQueueIf you find yourself reaching for a lock to protect ongoing mutable shared state, the type is almost always the wrong shape — promote it to an . The exception is the narrow lock carve-out below.
actorDo not introduce a single-method purely as a mutex. An whose only job is to guard a flag is a lock with extra ceremony: it forces synchronous callers — a termination handler, a event handler, a resume race — through , which adds suspension points, ordering hops, and reentrancy surface to what is fundamentally a synchronous compare-and-set. That makes the code worse, not safer. A tiny synchronous guard like that belongs in the lock carve-out, not an actor.
actoractor Guard { func claim() -> Bool }ProcessDispatchSourcewithCheckedContinuationTask { await guard.claim() }When extracting existing code that uses a forbidden primitive into a package, reconsider the shape at the seam rather than copying it blindly — usually it wants an . But a one-shot single-resume guard (a termination handler vs. a timeout vs. a spawn failure racing to resume one ) is exactly a case the lock carve-out covers: keep a synchronous primitive, hidden behind the type. Drain pipes concurrently on detached tasks keyed by the raw fd (an is ; a is not).
actorProcesswithCheckedContinuationProcessInt32SendableFileHandleForbidden in new code (no exceptions without a written justification in the PR description):
- Locks. ,
NSLock,NSRecursiveLock,os_unfair_lock,OSAllocatedUnfairLock,pthread_mutex_t,Synchronization.Mutexused as a lock. UseDispatchSemaphoreisolation. Mutable shared state belongs in an actor; reads and writes areactor. (Narrow carve-out below: a lock is allowed where theasync/actoralternative would genuinely worsen the code, with justification.)async - KVO via subclassing. Any
NSObjectwhose purpose is to overrideclass Foo: NSObjector callobserveValue(forKeyPath:...). Replace withaddObserver(_:forKeyPath:...)NotificationCenter.default.notifications(named:), or theAsyncSequencetoken API at the seam only.NSKeyValueObservation - used as a synchronization primitive. A
DispatchQueueaccessed viaDispatchQueue(label:)to serialize mutable state is a lock with different syntax. Use anqueue.sync { ... }. Queues are fine for event delivery (e.g. aactorhandler), not for protecting state.DispatchSource - Combine for change propagation. No , no
@Published, noObservableObject/PassthroughSubject, noCurrentValueSubjectfor change observation. UseAnyCancellable(Observation framework, Swift 5.9+) for SwiftUI state, or@Observable/AsyncStreamfor cross-actor change propagation.AsyncSequence - Completion-handler APIs. Authoring a new public API with a or
(Result<T, Error>) -> Voidcallback is forbidden. Use(T?, Error?) -> Void. When wrapping a legacy callback at the boundary, useasync throws -> T/withCheckedContinuationand keep it confined to that one seam.withCheckedThrowingContinuation - . Annotate the destination with
DispatchQueue.main.async { ... }. Call sites either@MainActorthe main-isolated function or are themselvesawait.@MainActor - Sleeping as a synchronization substitute. /
Task.sleep(or any sleep) used to poll for a condition, to let state "settle" before reading it, or to race a callback/animation is forbidden — use a real signal (Clock.sleep,AsyncStream, a completion, a state change).NSKeyValueObservationis banned outright (it is neither cancellable-by-structure nor testable). A bounded, cancellable, intended delay or deadline is allowed under theDispatchQueue.asyncAftercarve-out below.Clock.sleep
Required shape:
- Mutable shared state → . Reads/writes/reset are
actor. Observers receiveasyncreturned by the actor.AsyncStream - SwiftUI view-render-friendly state → view-model that subscribes to the actor's
@Observable @MainActorand projects snapshots. Don't read actor state synchronously from view code.AsyncStream - Cross-process / cross-thread invariants → expressed via actor isolation, not via locks or queues.
- New public observable surfaces → or
AsyncStream. Not callbacks, notAsyncSequence, not raw@Publishedsubscription.NotificationCenter
Acceptable with a one-line justification comment on the declaration:
These low-level primitives have no async-native replacement. They must be hidden behind an or surface; callers never see them.
AsyncStreamactor- for file watching (no Foundation async equivalent).
DispatchSource.makeFileSystemObjectSource - /
DispatchSource.makeReadSourcefor low-level socket I/O.makeWriteSource - A bounded, cancellable (preferred) or
Clock.sleepfor a genuine delay/deadline that is itself the intended behavior — a minimum display duration, an auto-dismiss, a check timeout. Drive it from an injectedTask.sleep(or a duration) so tests advance virtual time with no real waiting, and wire the sleepingClock's cancellation to the relevant lifecycle so a state transition cancels the pending delay (store theTask, cancel it on transition; or useTask). For true delays/deadlines only, never to poll, settle, or race — those still require a real signal. One-line justification on the call site.withTaskCancellationHandler - (one-shot) only when a genuine deadline must fire outside any async context — a non-
DispatchSource.makeTimerSourcetype with noasyncto host the sleep. Prefer theTaskcarve-out above whenever the code is already on an actor or in async code (it is cancellation-integrated and testable; a rawClock.sleeptimer is not, and has suspend/resume/cancel footguns). Hide the timer behind the type, cancel it on the non-timeout path, and never use it to poll or fake a sleep.DispatchSource - A lock for a synchronous compare-and-set called from non-async callbacks, where promoting to an would only add
actor/Taskhops and reentrancy. The canonical case is a one-shot resume guard: several synchronousawait/Processcallbacks race to resume oneDispatchSourceexactly once.withCheckedContinuationguarding aOSAllocatedUnfairLock(initialState:)(claimed once, checked synchronously in each callback) is correct, deterministic, and lets the callback resume the continuation inline. This carve-out is for short, non-blocking critical sections over a tiny flag/counter — not for guarding ongoing domain state (that is still anBool). Keep it private to the type, with a one-line justification.actor - token (the closure-based API) when wrapping a Foundation/AppKit type that exposes change only via KVO.
NSKeyValueObservation
@unchecked Sendablenonisolated(unsafe)Both require a comment on the declaration explaining the safety argument. Examples that pass review:
swift
// Wraps DispatchSourceFileSystemObject; every mutation happens on `queue`.
private final class WatcherAttachment: @unchecked Sendable { ... }
// UserDefaults is Apple-documented thread-safe; OK to read nonisolated.
private nonisolated(unsafe) let defaults: UserDefaultsWithout a justification comment, the diff is rejected. on an entire actor or struct is almost always wrong; prefer on the single non-Sendable property.
@unchecked Sendablenonisolated(unsafe) letScope and enforcement:
- Applies to: every new file in , every new file in the app target, every meaningful rewrite of an existing Swift file.
Packages/ - Existing app target code may continue to use the old primitives until rewritten. Do not retrofit blindly.
- Code review checklist (Codex, CodeRabbit, Greptile, and human reviewers): reject diffs that introduce /
@Published/ObservableObject/DispatchQueue.main.async/addObserver(_:forKeyPath:...)in new code, orDispatchQueue.asyncAfter/Task.sleepused to poll, settle, or race rather than as a bounded, cancellable, injected-clock delay with justification. Reject a lock (Clock.sleep/NSLock/etc.) orOSAllocatedUnfairLock/@unchecked Sendableunless it falls under a documented carve-out and carries a one-line justification — and reject a single-methodnonisolated(unsafe)that exists only to guard a flag (use the lock carve-out instead).actor
Packages/actorasyncawaitAsyncStreamAsyncSequence@Observable@MainActor@PublishedDispatchQueue如果发现自己需要用锁来保护持续的可变共享状态,那类型的设计几乎总是错误的——应将其升级为。以下是窄范围的锁例外情况。
actor不要仅为了互斥而引入单方法:的唯一作用是保护标志,这是带有额外仪式的锁:它会迫使同步调用者——终止处理程序、事件处理程序、恢复竞争——通过调用,这会为本质上是同步的比较并设置操作添加挂起点、顺序跳转和重入面。这会让代码变得更糟,而非更安全。这种微小的同步保护属于锁例外情况,而非actor。
actoractor Guard { func claim() -> Bool }ProcessDispatchSourcewithCheckedContinuationTask { await guard.claim() }在提取使用禁用原语的现有代码到包中时,应重新考虑隔离层的设计,而非盲目复制——通常应改为。但一次性恢复保护(例如终止处理程序、超时、生成失败竞争以恢复单个)正好属于锁例外情况:保留同步原语,隐藏在类型内部。在由原始fd(是;不是)标识的分离任务上并发读取管道。
actorProcesswithCheckedContinuationInt32SendableFileHandleProcess新代码中禁止使用(PR描述中无书面理由则无例外):
- 锁:、
NSLock、NSRecursiveLock、os_unfair_lock、OSAllocatedUnfairLock、pthread_mutex_t、用作锁的Synchronization.Mutex。使用DispatchSemaphore隔离。可变共享状态应放在actor中;读取和写入是actor操作。(以下窄范围例外:在async/actor替代方案确实会使代码变差的情况下,允许使用锁并附上理由。)async - 通过子类化实现KVO:任何
NSObject,其目的是重写class Foo: NSObject或调用observeValue(forKeyPath:...)。替换为addObserver(_:forKeyPath:...)NotificationCenter.default.notifications(named:),或仅在隔离层使用AsyncSequence令牌API。NSKeyValueObservation - 用作同步原语的:通过
DispatchQueue访问的queue.sync { ... }用于序列化可变状态,本质上是语法不同的锁。使用DispatchQueue(label:)。队列适用于事件传递(例如actor处理程序),而非保护状态。DispatchSource - 使用Combine进行变更传播:禁止使用、
@Published、ObservableObject/PassthroughSubject、CurrentValueSubject进行变更观察。对于SwiftUI状态,使用AnyCancellable(Observation框架,Swift 5.9+);对于跨actor变更传播,使用@Observable/AsyncStream。AsyncSequence - 完成处理程序API:禁止编写带有或
(Result<T, Error>) -> Void回调的新公共API。使用(T?, Error?) -> Void。在边界包装遗留回调时,使用async throws -> T/withCheckedContinuation并将其限制在该隔离层内。withCheckedThrowingContinuation - :在目标函数上标注
DispatchQueue.main.async { ... }。调用点要么@MainActor主线程隔离的函数,要么自身属于await。@MainActor - 用睡眠替代同步:禁止使用/
Task.sleep(或任何睡眠)来轮询条件、等待状态“稳定”后再读取,或竞争回调/动画——使用真实信号(Clock.sleep、AsyncStream、完成信号、状态变更)。完全禁止NSKeyValueObservation(它既无法通过结构取消,也无法测试)。仅在以下例外情况允许使用DispatchQueue.asyncAfter:有界、可取消、预期的延迟或截止时间。Clock.sleep
要求的设计形状:
- 可变共享状态 → 。读取/写入/重置是
actor操作。观察者接收actor返回的async。AsyncStream - 适合SwiftUI视图渲染的状态 → 视图模型,订阅actor的
@Observable @MainActor并提供快照。不要从视图代码同步读取actor状态。AsyncStream - 跨进程/跨线程不变量 → 通过actor隔离表达,而非锁或队列。
- 新的公共可观察表面 → 或
AsyncStream。不使用回调、AsyncSequence或原始@Published订阅。NotificationCenter
添加一行理由注释后允许使用:
这些低级原语没有异步原生替代方案。它们必须隐藏在或表面之后;调用者永远看不到它们。
AsyncStreamactor- 用于文件监控(Foundation无异步等效API)。
DispatchSource.makeFileSystemObjectSource - /
DispatchSource.makeReadSource用于低级套接字I/O。makeWriteSource - 有界、可取消的(优先)或
Clock.sleep用于真实的延迟/截止时间——这是预期行为的一部分,例如最小显示时长、自动关闭、检查超时。从注入的Task.sleep(或时长)驱动延迟,以便测试可以推进虚拟时间而无需真实等待,并将睡眠Clock的取消与相关生命周期关联,以便状态转换时取消待处理的延迟(存储Task,转换时取消;或使用Task)。仅适用于真实的延迟/截止时间,绝不用于轮询、等待稳定或竞争——这些仍需使用真实信号。在调用点添加一行理由注释。withTaskCancellationHandler - (一次性)仅当真实截止时间必须在任何异步上下文之外触发时——非
DispatchSource.makeTimerSource类型且无async来承载睡眠。只要代码已在actor或异步代码中,优先选择上述Task例外情况(它集成了取消功能且可测试;原始Clock.sleep定时器不具备这些特性,且有挂起/恢复/取消的陷阱)。将定时器隐藏在类型内部,在非超时路径上取消它,绝不用于轮询或模拟睡眠。DispatchSource - 用于同步比较并设置的锁,从非异步回调调用,此时升级为只会增加
actor/Task跳转和重入面。典型场景是一次性恢复保护:多个同步await/Process回调竞争以恢复单个DispatchSource恰好一次。withCheckedContinuation保护OSAllocatedUnfairLock(initialState:)(仅声明一次,在每个回调中同步检查)是正确、确定性的,且允许回调内联恢复continuation。此例外情况适用于小范围、非阻塞的临界区,保护微小的标志/计数器——而非保护持续的业务域状态(仍需使用Bool)。将其保持为类型私有,并添加一行理由注释。actor - 令牌(基于闭包的API),当包装仅通过KVO暴露变更的Foundation/AppKit类型时。
NSKeyValueObservation
@unchecked Sendablenonisolated(unsafe)两者都需要在声明上添加注释,解释安全性理由。通过评审的示例:
swift
// Wraps DispatchSourceFileSystemObject; every mutation happens on `queue`.
private final class WatcherAttachment: @unchecked Sendable { ... }
// UserDefaults is Apple-documented thread-safe; OK to read nonisolated.
private nonisolated(unsafe) let defaults: UserDefaults若无理由注释,代码变更将被拒绝。在整个actor或结构体上使用几乎总是错误的;优先在单个非Sendable属性上使用。
@unchecked Sendablenonisolated(unsafe) let范围与执行:
- 适用范围:中的每个新文件、应用目标中的每个新文件、每个现有Swift文件的大幅重写。
Packages/ - 现有应用目标代码可继续使用旧原语,直至重写。不要盲目改造。
- 代码评审检查清单(Codex、CodeRabbit、Greptile及人工评审):拒绝在新代码中引入/
@Published/ObservableObject/DispatchQueue.main.async/addObserver(_:forKeyPath:...)的变更,或使用DispatchQueue.asyncAfter/Task.sleep进行轮询、等待稳定或竞争而非有界、可取消、注入时钟延迟且无理由的变更。拒绝使用锁(Clock.sleep/NSLock等)或OSAllocatedUnfairLock/@unchecked Sendable的变更,除非属于文档化的例外情况且带有一行理由注释——拒绝仅为保护标志而存在的单方法nonisolated(unsafe)(改用锁例外情况)。actor