diff --git a/.gitignore b/.gitignore index 880ac59..7958e44 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ build/ +dist/ +temp/ +__pycache__/ .DS_Store diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 688fcfd..0e84ea4 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,6 +1,6 @@ # Tail Console 源码与二次开发 -这份源码对应 Tail Console 1.1.0。可复制到其他 Mac,按设备需求修改、测试和重新构建。每台设备连接自己的本机 daemon,不共享账户或设备身份。 +这份工作副本为 Tail Console 1.1.1 / build 3(Bleu 本机修订版),原始输入是 Blanc 共享的 1.1.0 源码包,校验依据保存在 `provenance/SOURCE.json`。可复制到其他 Mac,按设备需求修改、测试和重新构建。每台设备连接自己的本机 daemon,不共享账户或设备身份。 ## 环境与准备 @@ -15,18 +15,18 @@ 在解压得到的源码文件夹中打开终端,执行: ```sh -./scripts/test.sh -./scripts/model-test.sh +./scripts/test-all.sh ./scripts/build.sh ``` -前两项分别验证核心逻辑和 16 组离线模型场景,使用合成数据与内存 mock,不修改真实 daemon。构建完成后,应用位于 `build/Tail Console.app`。产物采用本机 ad-hoc 签名,脚本会检查签名。 +测试包括核心逻辑与真实受控子进程、24 组离线模型场景、24 项原生编辑菜单检查、17 项服务诊断和测量脚本回归;使用合成数据与内存 mock,不修改真实 daemon。原生菜单测试在隐藏窗口操作临时剪贴板内容并在结束时恢复。构建完成后,应用位于 `build/Tail Console.app`。产物采用本机 ad-hoc 签名,脚本会检查签名。各测试脚本也可单独运行。 需要安装到本机时,先退出已运行的 Tail Console,再把生成的应用放入“应用程序”文件夹。无需停止或重新注册 daemon。 ## 文件分工 - `Sources/App.swift`:菜单栏、弹出面板、独立窗口与启动行为。 +- `Sources/ApplicationMenu.swift`:主菜单与标准编辑快捷键的 responder-chain 分发。 - `Sources/Views.swift`:设备列表、复制 IP、连接开关和出口设置界面。 - `Sources/ConsoleModel.swift`:刷新频率、操作串行、错误显示和设置回读。 - `Sources/Core.swift`:设备与偏好模型、命令参数、进程执行和输出处理。 diff --git a/README.md b/README.md index 778705a..ae46a5d 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,41 @@ # Tail Console -本机 Tailscale 系统后台服务的 macOS 菜单栏控制面板。安装位置:`/Applications/Tail Console.app`。 +本机 Tailscale 系统 daemon 的 macOS 菜单栏与独立控制台。当前开发版本为 **1.1.1 / build 3(Bleu 本机修订版)**,由 Blanc 共享平台发布的 1.1.0 源码发展;不是 Tailscale 官方产品。 -## 使用 - -- 点击菜单栏的三点网络图标打开小面板;双击应用可打开独立控制台。 -- 查看本机和其他设备的名称、IP、在线状态,支持搜索及在线/全部筛选。 -- 点击设备复制 IP(优先 IPv4),右键菜单可复制 IPv6;兼容仅有 IPv6 的设备。 -- 连接开关开启/关闭 Tailscale 连接;后台服务继续保留开机启动。关闭连接的设置会跨重启保存。 -- 从已获批准的在线设备中选择出口。取消出口会恢复直接访问互联网。 -- “让本机提供出口”申请将本机作为出口,是否可用以 Tailscale 管理台批准为准。使用其他出口与本机提供出口互斥。 -- “登录时打开面板”仅控制此应用;系统 daemon 在用户登录前运行,不依赖面板。 -- 面板展开时每 5 秒刷新;全部面板关闭、最小化或隐藏后改为约 30 秒。打开时立即刷新。服务开机设置最多每 30 秒检查一次。未连接或读取失败时,设备列表明确标为上次记录。 - -当前使用原有账号和系统服务,不另建账户,不保存登录凭据,不开放 Web 端口。 - -## 构建与验证 - -需要 macOS 14 或更高版本、Apple Silicon 和 Apple Command Line Tools,无第三方依赖。 - -```sh -./scripts/build.sh -./scripts/test.sh -./scripts/model-test.sh -``` - -产物:`build/Tail Console.app`。更新前先退出面板,再用 `ditto` 将构建产物复制到 `/Applications/Tail Console.app`。本机采用 ad-hoc 签名,不是用于分发的 Developer ID 签名。 +原始源码包、发布清单和逐文件校验值保存在 `provenance/`。本地修改不会自动上传或覆盖 Blanc 的发布版本。开发与重建说明见 [DEVELOPMENT.md](DEVELOPMENT.md),修订说明见 [REVIEW.md](REVIEW.md)。本机工作副本中的 `reports/2026-10-16-review/` 保存详细审阅记录、测试日志与性能原始数据,不随应用分发。 -## 控制接口 - -优先使用 `/usr/local/bin/tailscale`,并兼容 Homebrew 常见安装路径;始终明确指定 `--socket=/var/run/tailscaled.socket`,避免连接旧图形版。通过 argv 传参,不经过 shell。现有 daemon 已为本机用户设置 operator,无需修改 sudoers 或以 root 运行面板。 - -读取状态使用 `status --json` 和 `debug prefs`;连接恢复使用无附加选项的 `up`,保留现有全部设置。设置出口使用 `set`,只更改对应选项。读写命令有超时保护;操作串行,执行期间禁用开关。设置完成后重新读取状态,只有实际设置符合预期才显示成功。操作错误保留到下次操作或手动关闭,不会被正常刷新清掉。 - -命令输出通过非阻塞内存管道读取,不为每次刷新创建临时磁盘文件;标准输出和错误输出各设 8 MiB 上限。命令超时后会停止子进程并清理读取任务。后代进程继续持有管道时,读取任务也有独立的取消机制。 - -`debug prefs` 属于 Tailscale 非稳定接口。升级 daemon 后应再次验证界面显示与操作;读取失败时面板禁用控制,避免依据旧状态修改设置。 - -源码从 `Sources/` 构建,并随应用保存在 `Contents/Resources/Project/`,供“项目文件”菜单查看,不依赖原工作区绝对路径。 - -## 2026-10-16 本机审阅 +## 使用 -版本 1.1.0 已完成独立只读审阅、核心测试和 16 组离线模型测试。核心测试覆盖 IPv6、出口资格与模式互斥、无附加选项重连、参数隔离、双路大输出、输出上限、启动失败及超时;模型测试覆盖错误保留与恢复、设置回读、并发刷新、重复操作防护和多视图轮询间隔。真实界面验证了连接关闭/恢复、设备复制与搜索、离线筛选、本机出口开关、登录启动登记,以及关闭窗口后 daemon 继续运行。 +- 菜单栏图标打开小面板,打开应用显示独立控制台;关闭面板不停止 daemon。 +- 设备列表支持名称、IPv4 和 IPv6 搜索,以及在线/全部筛选。点击复制优先 IPv4,右键可选择 IPv6;搜索框支持标准编辑快捷键。 +- 连接开关只开启/关闭 Tailscale 连接,保留系统 daemon。关闭连接的选择也会保留到下次开机。 +- 选择其他已批准且在线的出口设备,或恢复不使用出口节点。 +- “让本机提供出口”与使用其他出口互斥,实际可用性仍取决于管理台批准。 +- “登录时打开面板”只控制本应用;daemon 的开机启动独立于面板。 -本轮修正:后台查询频率和临时写入、已关闭视图的保留、读取失败时的误导文案、错误提示丢失、出口状态回读与稳定 ID 优先级、命令输出任务清理、安装路径兼容性,以及手动启动时显示控制台。 +运行要求:Apple Silicon、macOS 14+、已安装并登录的 tailscaled、当前用户的 operator 权限。应用明确使用 `/var/run/tailscaled.socket`;CLI 支持 `/usr/local/bin` 与常见 Homebrew 安装位置。它不安装 daemon、不共享其他设备身份、不保存登录凭据,也不开放 Web 端口。 -性能原始数据保存在 `build/perf-*.json`,测量脚本为 `scripts/measure.py`。每种状态采样 45 秒,属于短时本机观测,不能代替长期稳定性测试。CPU 统计区分面板和查询子进程;RSS 含共享系统框架,`footprint` 为系统计入该进程的内存占用。 +## 刷新与控制 -| 状态 | CPU:面板及查询子进程,占单核 | 内存 footprint 最大值 | RSS 最大值 | 采样期磁盘写入 | -| --- | ---: | ---: | ---: | ---: | -| 原版展开 | 0.0373% | 46.4 MiB | 122.9 MiB | 152 KiB | -| 修正版未展开 | 0.0057% | 13.5 MiB | 71.8 MiB | 0 | -| 修正版展开 | 0.0219% | 42.8 MiB | 131.3 MiB | 0 | -| 修正版使用后收起 | 0.0021% | 50.1 MiB | 142.0 MiB | 0 | +面板展开时约每 5 秒刷新,全部关闭/隐藏时约每 30 秒刷新;重复可见通知不会额外触发查询。服务开机设置每 30 秒以内检查一次。未连接或读取失败时,列表明确标识为旧记录,读取失败后禁用控制。 -界面使用后的系统/SwiftUI 缓存不会立即全部归还,因此不能把冷启动待机的 13.5 MiB 当作全天常态上限。完整结构化结果见 `build/review-results.json`。 +设置操作串行执行。操作开始前等待已有刷新完成并确认状态可读;操作期间暂停其他刷新,完成后回读实际设置。选择出口时再次核对最新设备资格和地址,避免按过时列表执行。读取失败或设置未确认时不会显示成功;操作错误不会被普通刷新清掉。 -尚未验证或有明确限制: +重新连接只使用无附加选项的 `up`,保留非默认路由等偏好;其他设置使用定向 `set`。命令参数通过独立 argv 传递,不拼接 shell。输出在内存中并行读取,每路上限 8 MiB;输出超限立即停止,取消与统一截止时间同时覆盖进程及管道读取。清理宽限和读取线程退出会增加少量时间,不是实时系统保证。 -- 当前网络没有获批的可用出口,未做经其他出口访问互联网的实流量测试;出口选择和互斥逻辑通过离线测试。 -- 未实际重启验收本版登录启动,也未进行长时间驻留测试。 -- 当前版本面向已经登录的 daemon;账号过期后的完整浏览器认证流程尚未集成。 -- `debug prefs` 为非稳定接口;升级 daemon 后需复核。服务启动检测适配官方 daemon 和 Homebrew 的常见系统服务名称,其他自定义服务显示未确认。 -- 构建目标仍为 Apple Silicon / macOS 14+,不宣称兼容 Intel。 +服务运行状态与开机配置分开判断;权限不足或无法识别时显示“未确认”。残留的另一种安装配置不会遮蔽已经运行的服务。 -给 Bleu 的分享与部署已按用户指示暂停,未向远端发送应用或安装包。 +## 验证与边界 -## 保留的系统组件 +运行 `./scripts/test-all.sh` 执行核心、模型、原生编辑菜单、服务诊断和测量脚本测试。测试使用合成设备与受控本地子进程,不更改真实 daemon。构建使用 `./scripts/build.sh`,产物为 `build/Tail Console.app`;全新组装 bundle 后再检查 ad-hoc 签名,避免旧构建文件残留。 -旧图形版移除后,应保留 `/usr/local/bin/tailscale`、`/usr/local/bin/tailscaled`、`/Library/LaunchDaemons/com.tailscale.tailscaled.plist` 和 `/Library/Tailscale/`。最后一个目录包含系统服务的敏感身份状态,不应上传或打印内容。 +当前限制: -MagicDNS 的系统解析由 daemon/macOS 当前配置决定,本面板不修改系统 DNS。 +- 真实获批出口流量、长期驻留及面板登录自启的重启验收,必须在目标设备单独验证;离线测试不能替代。 +- `debug prefs` 属于 Tailscale 非稳定接口,daemon 升级后需复核。 +- 账号过期后的完整浏览器重新认证流程尚未集成。 +- 仅构建 arm64/macOS 14+,不宣称兼容 Intel。 +- 对主动脱离所启动进程组的后代,不保证强制清理。 +- 当前采用 ad-hoc 签名,未作 Developer ID 签名和公证;若系统阻止打开,停止并排查,不绕过安全提示。 -官方接口参考:[Tailscale CLI](https://tailscale.com/docs/reference/tailscale-cli)、[macOS daemon](https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS)、[登录启动 SMAppService](https://developer.apple.com/documentation/servicemanagement/smappservice)。 +保留系统 CLI、tailscaled、其 LaunchDaemon 配置与敏感身份目录。不要把 daemon 状态、账号凭据或其他机器的身份复制到源码、报告或发布包中。本面板不修改系统 DNS。 diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..7cf4cec --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,23 @@ +# 1.1.1 / build 3 本机修订说明 + +维护者:Codex;日期:2026-10-16。基于 Blanc 共享平台的 1.1.0 源码(SHA-256 见 `provenance/SOURCE.json`),用于 Bleu 本机修订,不覆盖原发布。 + +## 修复 + +- 搜索所有设备地址,修复双栈设备无法按 IPv6 查找,并支持 IPv6 大小写。 +- 增加标准编辑菜单及 responder-chain 操作,使文本框支持粘贴、剪切、复制、全选、撤销与重做。 +- 修复关闭后立即重开窗口时的跟踪竞态。 +- 合并重复刷新,等待已有读取完成后再操作;读取失败时不继续执行操作,操作结束后单独回读。 +- 对出口选择再次核实最新资格与地址,避免使用刷新前的旧设备状态。 +- 修复任务取消无效、输出超限延迟到超时、后代持有管道超出截止时间的问题。读取线程结束后再关闭文件描述符。 +- 服务诊断不再依赖 plist 必须可读;陌生状态显示未确认,残留配置不会遮蔽实际运行的另一种受支持服务。 +- 测量工具按指定进程统计,纳入完成的子进程 CPU、初始内存样本,并保留原始数据和采样边界。 +- 构建使用全新 bundle,签名通过后替换旧的生成产物,避免残留已删除文件。 + +## 已完成的构建前检查 + +核心测试与新增 8 组子进程回归、24 组模型测试、24 项原生菜单检查、17 项服务诊断、5 项测量工具测试均通过。独立复核未发现待处理的明确回归。测试均未修改真实 daemon。 + +这份随包说明记录构建前的源码审阅。安装后的真实界面复测和性能数据,保存在维护工作副本的 `reports/2026-10-16-review/`,应以该报告的核实范围为准。 + +实际出口流量、面板登录启动的重启验收与长期驻留仍需目标环境测试。界面依赖 `debug prefs` 非稳定接口;主动脱离已确认进程组的后代不在强制清理保证内。签名仍为本机 ad-hoc,未作公证。 diff --git a/Sources/App.swift b/Sources/App.swift index ac59a40..daf661b 100644 --- a/Sources/App.swift +++ b/Sources/App.swift @@ -14,22 +14,7 @@ final class AppCoordinator: NSObject, NSApplicationDelegate, NSWindowDelegate, N func applicationDidFinishLaunching(_ notification: Notification) { NSApplication.shared.setActivationPolicy(.accessory) - let menu = NSMenu() - let appItem = NSMenuItem() - let appMenu = NSMenu(title: "Tail Console") - let openItem = NSMenuItem(title: "打开控制台", action: #selector(showConsole), keyEquivalent: "0") - openItem.target = self - appMenu.addItem(openItem) - appMenu.addItem(.separator()) - appMenu.addItem(withTitle: "退出 Tail Console", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") - appItem.submenu = appMenu - menu.addItem(appItem) - let windowItem = NSMenuItem() - let windowMenu = NSMenu(title: "窗口") - windowMenu.addItem(withTitle: "关闭窗口", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") - windowItem.submenu = windowMenu - menu.addItem(windowItem) - NSApplication.shared.mainMenu = menu + NSApplication.shared.mainMenu = ApplicationMenu.make(openConsoleTarget: self, openConsoleAction: #selector(showConsole)) statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) statusItem.button?.target = self statusItem.button?.action = #selector(togglePopover) @@ -95,8 +80,12 @@ final class AppCoordinator: NSObject, NSApplicationDelegate, NSWindowDelegate, N } } func windowWillClose(_ notification: Notification) { + guard let closingWindow = notification.object as? NSWindow, closingWindow === window else { return } model.setVisible("window", false) - DispatchQueue.main.async { [weak self] in self?.window = nil } + // The close notification retains its window during this callback. Clear + // ownership now so an immediate reopen cannot reuse a closing window or + // have the next run-loop turn discard a newly opened window. + window = nil } func windowDidMiniaturize(_ notification: Notification) { model.setVisible("window", false) } func windowDidDeminiaturize(_ notification: Notification) { model.setVisible("window", true) } diff --git a/Sources/ApplicationMenu.swift b/Sources/ApplicationMenu.swift new file mode 100644 index 0000000..8a3bee4 --- /dev/null +++ b/Sources/ApplicationMenu.swift @@ -0,0 +1,39 @@ +import AppKit + +@MainActor +enum ApplicationMenu { + static func make(openConsoleTarget: AnyObject, openConsoleAction: Selector) -> NSMenu { + let menu = NSMenu() + let appItem = NSMenuItem() + let appMenu = NSMenu(title: "Tail Console") + let openItem = NSMenuItem(title: "打开控制台", action: openConsoleAction, keyEquivalent: "0") + openItem.target = openConsoleTarget + appMenu.addItem(openItem) + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "退出 Tail Console", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + appItem.submenu = appMenu + menu.addItem(appItem) + + // Keep text commands on the responder chain so both the window and + // popover's native field editor receive their standard keyboard actions. + let editItem = NSMenuItem() + let editMenu = NSMenu(title: "编辑") + editMenu.addItem(withTitle: "撤销", action: Selector(("undo:")), keyEquivalent: "z") + let redoItem = editMenu.addItem(withTitle: "重做", action: Selector(("redo:")), keyEquivalent: "Z") + redoItem.keyEquivalentModifierMask = [.command, .shift] + editMenu.addItem(.separator()) + editMenu.addItem(withTitle: "剪切", action: #selector(NSText.cut(_:)), keyEquivalent: "x") + editMenu.addItem(withTitle: "复制", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + editMenu.addItem(withTitle: "粘贴", action: #selector(NSText.paste(_:)), keyEquivalent: "v") + editMenu.addItem(withTitle: "全选", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + editItem.submenu = editMenu + menu.addItem(editItem) + + let windowItem = NSMenuItem() + let windowMenu = NSMenu(title: "窗口") + windowMenu.addItem(withTitle: "关闭窗口", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + windowItem.submenu = windowMenu + menu.addItem(windowItem) + return menu + } +} diff --git a/Sources/ConsoleModel.swift b/Sources/ConsoleModel.swift index 64ab841..5faa115 100644 --- a/Sources/ConsoleModel.swift +++ b/Sources/ConsoleModel.swift @@ -16,14 +16,14 @@ final class ConsoleModel: ObservableObject { @Published var copiedID: String? @Published var loginEnabled = false @Published var loginApproval = false - @Published var serviceLoaded = false + @Published var serviceLoaded: Bool? @Published var serviceBootEnabled: Bool? @Published var serviceKeepAlive = false @Published var query = "" @Published var onlyOnline = true private var timer: Timer? - private var refreshing = false + private var refreshTask: Task? private var serviceChecked = Date.distantPast private let client: any DaemonClient private let automatic: Bool @@ -39,10 +39,10 @@ final class ConsoleModel: ObservableObject { var connected: Bool { status?.connected == true && fresh } var peers: [Device] { (status?.peers ?? []).filter { device in - (!onlyOnline || device.online) && (query.isEmpty || device.name.localizedCaseInsensitiveContains(query) || device.ip.contains(query)) + (!onlyOnline || device.online) && (query.isEmpty || device.name.localizedCaseInsensitiveContains(query) || (device.TailscaleIPs ?? []).contains { $0.localizedCaseInsensitiveContains(query) }) } } - var onlineCount: Int { status?.peers.filter(\.online).count ?? 0 } + var onlineCount: Int { status?.Peer?.values.reduce(0) { $0 + ($1.online ? 1 : 0) } ?? 0 } var connectionTitle: String { if !fresh { return loading ? "读取状态中" : "无法连接服务" } switch status?.BackendState { @@ -71,7 +71,8 @@ final class ConsoleModel: ObservableObject { } func setVisible(_ surface: String, _ visible: Bool) { - if visible { visibleSurfaces.insert(surface) } else { visibleSurfaces.remove(surface) } + let changed = visible ? visibleSurfaces.insert(surface).inserted : visibleSurfaces.remove(surface) != nil + guard changed else { return } scheduleRefresh() if visible { Task { await refresh() } } } @@ -90,9 +91,24 @@ final class ConsoleModel: ObservableObject { } func refresh() async { - guard !refreshing else { return } - refreshing = true - defer { refreshing = false; loading = false } + // The operation performs its own readback once the command is complete. + // Incidental UI refreshes must not publish an intermediate command state. + guard !busy else { return } + await readback() + } + + private func readback() async { + if let refreshTask { await refreshTask.value; return } + let task = Task { + await readSnapshot() + refreshTask = nil + } + refreshTask = task + await task.value + } + + private func readSnapshot() async { + defer { loading = false } do { async let statusData = client.call(["status", "--json"], allowFailure: true) async let prefsData = client.call(["debug", "prefs"], allowFailure: false) @@ -128,22 +144,39 @@ final class ConsoleModel: ObservableObject { private func perform(_ action: ControlAction, success: String) { guard mayControl else { return } - let arguments: [String] - do { arguments = try action.arguments() } + do { _ = try action.arguments() } catch { actionError = error.localizedDescription; return } busy = true actionError = nil notice = nil Task { + // Finish any older read before changing preferences. Awaiting the + // shared task avoids a 50 ms wakeup loop and preserves read ordering. + if let refreshTask { await refreshTask.value } + guard fresh, prefs != nil else { busy = false; return } + let verifiedAction: ControlAction + if case .exit(let selected?) = action { + guard let current = status?.Peer?.values.first(where: { $0.id == selected.id }) else { + actionError = ControlError.invalidExit.localizedDescription + busy = false + return + } + verifiedAction = .exit(current) + } else { verifiedAction = action } + let arguments: [String] + do { arguments = try verifiedAction.arguments() } + catch { actionError = error.localizedDescription; busy = false; return } var failure: String? do { _ = try await client.call(arguments, allowFailure: false) } catch { failure = error.localizedDescription } - while refreshing { try? await Task.sleep(nanoseconds: 50_000_000) } - await refresh() + await readback() busy = false if let failure { actionError = failure } else if fresh, let status, let prefs { - if action.confirmed(status: status, prefs: prefs) { notice = success } + if verifiedAction.confirmed(status: status, prefs: prefs) { + if case .exit(let device?) = verifiedAction { notice = "已选择出口:" + device.name } + else { notice = success } + } else { actionError = ControlError.notApplied.localizedDescription } } } diff --git a/Sources/Core.swift b/Sources/Core.swift index 232ac1f..16cc08a 100644 --- a/Sources/Core.swift +++ b/Sources/Core.swift @@ -144,11 +144,12 @@ private final class OutputBuffer: @unchecked Sendable { private let lock = NSLock() private var contents = Data() private var tooLarge = false - func append(_ data: Data) { + @discardableResult func append(_ data: Data) -> Bool { lock.lock(); defer { lock.unlock() } let room = max(0, 8 * 1024 * 1024 - contents.count) contents.append(data.prefix(room)) if data.count > room { tooLarge = true } + return !tooLarge } func result() throws -> Data { lock.lock(); defer { lock.unlock() } @@ -157,80 +158,137 @@ private final class OutputBuffer: @unchecked Sendable { } } -private final class ReadCancellation: @unchecked Sendable { +private final class CommandControl: @unchecked Sendable { private let lock = NSLock() - private var cancelled = false - var isCancelled: Bool { lock.lock(); defer { lock.unlock() }; return cancelled } - func cancel() { lock.lock(); cancelled = true; lock.unlock() } + private var failure: Error? + private var readersCancelled = false + let wake = DispatchSemaphore(value: 0) + + var error: Error? { lock.lock(); defer { lock.unlock() }; return failure } + var shouldStopReading: Bool { lock.lock(); defer { lock.unlock() }; return readersCancelled } + func stop(_ error: Error) { + lock.lock() + if failure == nil { failure = error } + lock.unlock() + wake.signal() + } + func cancelReaders() { lock.lock(); readersCancelled = true; lock.unlock() } } // Commands use argv directly. Neither peer names nor UI input ever reach a shell. enum CommandRunner { static func run(_ executable: String, _ arguments: [String], timeout: TimeInterval = 18) async throws -> ProcessResult { - try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .utility).async { - let output = Pipe(), errorOutput = Pipe() - let outBuffer = OutputBuffer(), errBuffer = OutputBuffer() - let readers = DispatchGroup() - let cancellation = ReadCancellation() - do { - defer { try? output.fileHandleForReading.close(); try? errorOutput.fileHandleForReading.close() } - let process = Process() - let ended = DispatchSemaphore(value: 0) - process.terminationHandler = { _ in ended.signal() } - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - process.standardInput = FileHandle.nullDevice - process.standardOutput = output - process.standardError = errorOutput - for pipe in [output, errorOutput] { - let fd = pipe.fileHandleForReading.fileDescriptor - let flags = fcntl(fd, F_GETFL) - guard flags >= 0, fcntl(fd, F_SETFL, flags | O_NONBLOCK) >= 0 else { throw ControlError.command("无法准备命令输出读取。") } - } - try process.run() - for (pipe, buffer) in [(output, outBuffer), (errorOutput, errBuffer)] { - readers.enter() - DispatchQueue.global(qos: .utility).async { - defer { readers.leave() } - let fd = pipe.fileHandleForReading.fileDescriptor - var bytes = [UInt8](repeating: 0, count: 65536) - while !cancellation.isCancelled { - var descriptor = pollfd(fd: fd, events: Int16(POLLIN | POLLHUP), revents: 0) - let ready = poll(&descriptor, 1, 100) - if ready == 0 { continue } - if ready < 0 { if errno == EINTR { continue }; return } - if descriptor.revents & Int16(POLLNVAL) != 0 { return } - while !cancellation.isCancelled { - let count = Darwin.read(fd, &bytes, bytes.count) - if count > 0 { buffer.append(Data(bytes.prefix(count))); continue } - if count == 0 { return } - if errno == EINTR { continue } - if errno == EAGAIN || errno == EWOULDBLOCK { break } + let control = CommandControl() + let deadline = DispatchTime.now() + max(0, timeout) + return try await withTaskCancellationHandler(operation: { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + do { continuation.resume(returning: try execute(executable, arguments, deadline: deadline, control: control)) } + catch { continuation.resume(throwing: error) } + } + } + }, onCancel: { control.stop(CancellationError()) }) + } + + private static func execute(_ executable: String, _ arguments: [String], deadline: DispatchTime, control: CommandControl) throws -> ProcessResult { + let output = Pipe(), errorOutput = Pipe() + let outBuffer = OutputBuffer(), errBuffer = OutputBuffer() + let readers = DispatchGroup() + let process = Process() + var launched = false + var processGroup: pid_t? + // A reader must finish before its file descriptor can be closed + // or reused. Nonblocking reads and 100ms polling bound this join. + defer { + control.cancelReaders() + readers.wait() + for pipe in [output, errorOutput] { + try? pipe.fileHandleForReading.close() + try? pipe.fileHandleForWriting.close() + } + } + do { + if let error = control.error { throw error } + if DispatchTime.now() >= deadline { throw ControlError.timeout } + process.terminationHandler = { _ in control.wake.signal() } + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.standardInput = FileHandle.nullDevice + process.standardOutput = output + process.standardError = errorOutput + for pipe in [output, errorOutput] { + let fd = pipe.fileHandleForReading.fileDescriptor + let flags = fcntl(fd, F_GETFL) + guard flags >= 0, fcntl(fd, F_SETFL, flags | O_NONBLOCK) >= 0 else { throw ControlError.command("无法准备命令输出读取。") } + } + try process.run() + launched = true + let pid = process.processIdentifier + // Only signal a group when Foundation gave this command its + // own group. Never signal the caller's process group. + if getpgid(pid) == pid { processGroup = pid } + for (pipe, buffer) in [(output, outBuffer), (errorOutput, errBuffer)] { + readers.enter() + DispatchQueue.global(qos: .utility).async { + defer { readers.leave(); control.wake.signal() } + let fd = pipe.fileHandleForReading.fileDescriptor + var bytes = [UInt8](repeating: 0, count: 65536) + while !control.shouldStopReading { + var descriptor = pollfd(fd: fd, events: Int16(POLLIN | POLLHUP), revents: 0) + let ready = poll(&descriptor, 1, 100) + if ready == 0 { continue } + if ready < 0 { if errno == EINTR { continue }; return } + if descriptor.revents & Int16(POLLNVAL) != 0 { return } + while !control.shouldStopReading { + let count = Darwin.read(fd, &bytes, bytes.count) + if count > 0 { + guard buffer.append(Data(bytes.prefix(count))) else { + control.stop(ControlError.outputTooLarge) return } + continue } + if count == 0 { return } + if errno == EINTR { continue } + if errno == EAGAIN || errno == EWOULDBLOCK { break } + return } } - let timedOut = ended.wait(timeout: .now() + timeout) == .timedOut - if timedOut && process.isRunning { - process.terminate() - if ended.wait(timeout: .now() + 1) == .timedOut && process.isRunning { Darwin.kill(process.processIdentifier, SIGKILL) } - } - process.waitUntilExit() - let drained = readers.wait(timeout: .now() + 2) == .success - if !drained { - cancellation.cancel() - readers.wait() - } - guard drained, !timedOut else { throw ControlError.timeout } - continuation.resume(returning: ProcessResult(code: process.terminationStatus, output: try outBuffer.result(), error: try errBuffer.result())) - } catch { - cancellation.cancel() - try? output.fileHandleForWriting.close(); try? errorOutput.fileHandleForWriting.close() - continuation.resume(throwing: error) } } + while true { + if let error = control.error { throw error } + if DispatchTime.now() >= deadline { throw ControlError.timeout } + if !process.isRunning && readers.wait(timeout: .now()) == .success { break } + _ = control.wake.wait(timeout: deadline) + } + if let error = control.error { throw error } + return ProcessResult(code: process.terminationStatus, output: try outBuffer.result(), error: try errBuffer.result()) + } catch { + control.cancelReaders() + if launched { stop(process, group: processGroup, wake: control.wake) } + throw error + } + } + + private static func stop(_ process: Process, group: pid_t?, wake: DispatchSemaphore) { + let pid = process.processIdentifier + if let group { Darwin.kill(-group, SIGTERM) } + else if process.isRunning { Darwin.kill(pid, SIGTERM) } + let grace = DispatchTime.now() + 0.2 + while DispatchTime.now() < grace { + let groupAlive = group.map { Darwin.kill(-$0, 0) == 0 } ?? false + if !process.isRunning && !groupAlive { return } + _ = wake.wait(timeout: min(grace, .now() + 0.01)) + } + if let group { Darwin.kill(-group, SIGKILL) } + else if process.isRunning { Darwin.kill(pid, SIGKILL) } + // SIGKILL cannot be ignored. Avoid an unbounded waitUntilExit if the OS + // delays reaping; Foundation still owns the process termination handler. + let reaping = DispatchTime.now() + 0.2 + while process.isRunning && DispatchTime.now() < reaping { + _ = wake.wait(timeout: min(reaping, .now() + 0.01)) } } static func tailscale(_ arguments: [String], allowFailure: Bool = false) async throws -> Data { diff --git a/Sources/DaemonClient.swift b/Sources/DaemonClient.swift index 0eb7e9b..13054a7 100644 --- a/Sources/DaemonClient.swift +++ b/Sources/DaemonClient.swift @@ -1,11 +1,62 @@ import Foundation struct ServiceState: Equatable { - var running = false + var running: Bool? var bootEnabled: Bool? var keepAlive = false } +struct ServiceObservation { + let label: String + let definitionExists: Bool + let plistData: Data? + let job: ProcessResult? +} + +// A missing permission or an unexpected launchctl response is not evidence +// that the daemon is stopped. Keep unknown states distinct from false. +enum ServiceDiagnostics { + static func state(label: String, plistData: Data?, job: ProcessResult?, overrides: ProcessResult?) -> ServiceState { + let plist = plistData.flatMap { try? PropertyListSerialization.propertyList(from: $0, format: nil) as? [String: Any] } + var running: Bool? + if let job, job.code == 0 { + let lines = String(decoding: job.output, as: UTF8.self).split(separator: "\n") + if let line = lines.first(where: { $0.trimmingCharacters(in: .whitespaces).hasPrefix("state = ") }) { + let value = String(line.trimmingCharacters(in: .whitespaces).dropFirst("state = ".count)) + if value == "running" { running = true } + else if ["waiting", "not running", "exited", "stopped"].contains(value) { running = false } + } + } else if let job { + let message = String(decoding: job.error + job.output, as: UTF8.self) + if message.contains("Could not find service") { running = false } + } + + var boot: Bool? + if let plist, let overrides, overrides.code == 0 { + let text = String(decoding: overrides.output, as: UTF8.self) + let off = text.contains("\"" + label + "\" => disabled") + let on = text.contains("\"" + label + "\" => enabled") + let launch = plist["RunAtLoad"] as? Bool == true || plist["KeepAlive"] as? Bool == true + let conditional = plist["KeepAlive"] as? [String: Any] != nil + if off { boot = false } + else if launch { boot = on || plist["Disabled"] as? Bool != true } + else if !conditional { boot = false } + } + return ServiceState(running: running, bootEnabled: boot, keepAlive: plist?["KeepAlive"] as? Bool == true) + } + + static func preferred(_ observations: [ServiceObservation], overrides: ProcessResult?) -> ServiceState { + let candidates = observations.map { observation in + (observation, state(label: observation.label, plistData: observation.plistData, job: observation.job, overrides: overrides)) + } + // A stale plist must not mask the other supported installation's job. + let selected = candidates.first { $0.1.running == true } + ?? candidates.first { $0.0.job?.code == 0 } + ?? candidates.first { $0.0.definitionExists } + return selected?.1 ?? ServiceState() + } +} + protocol DaemonClient: Sendable { func call(_ arguments: [String], allowFailure: Bool) async throws -> Data func service() async -> ServiceState @@ -16,23 +67,17 @@ struct LocalDaemonClient: DaemonClient { try await CommandRunner.tailscale(arguments, allowFailure: allowFailure) } func service() async -> ServiceState { + async let overrides = try? CommandRunner.run("/bin/launchctl", ["print-disabled", "system"]) + var observations: [ServiceObservation] = [] for label in ["com.tailscale.tailscaled", "homebrew.mxcl.tailscale"] { let url = URL(fileURLWithPath: "/Library/LaunchDaemons/" + label + ".plist") - guard let data = try? Data(contentsOf: url), let dict = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { continue } - async let job = try? CommandRunner.run("/bin/launchctl", ["print", "system/" + label]) - async let overrides = try? CommandRunner.run("/bin/launchctl", ["print-disabled", "system"]) - let (result, disabled) = await (job, overrides) - var boot: Bool? - if let disabled, disabled.code == 0 { - let text = String(decoding: disabled.output, as: UTF8.self) - let off = text.contains("\"" + label + "\" => disabled") - let on = text.contains("\"" + label + "\" => enabled") - let defaultOff = dict["Disabled"] as? Bool == true - let launch = dict["RunAtLoad"] as? Bool == true || dict["KeepAlive"] as? Bool == true - boot = launch && !off && (on || !defaultOff) - } - return ServiceState(running: result?.code == 0 && String(decoding: result?.output ?? Data(), as: UTF8.self).contains("state = running"), bootEnabled: boot, keepAlive: dict["KeepAlive"] as? Bool == true) + let data = try? Data(contentsOf: url) + let job = try? await CommandRunner.run("/bin/launchctl", ["print", "system/" + label]) + // The official installer may create a root-only plist. Query the + // job regardless, so the running service is still detected. + observations.append(ServiceObservation(label: label, definitionExists: FileManager.default.fileExists(atPath: url.path), plistData: data, job: job)) + if ServiceDiagnostics.state(label: label, plistData: data, job: job, overrides: nil).running == true { break } } - return ServiceState() + return ServiceDiagnostics.preferred(observations, overrides: await overrides) } } diff --git a/Sources/Views.swift b/Sources/Views.swift index bb92795..e5a5ee0 100644 --- a/Sources/Views.swift +++ b/Sources/Views.swift @@ -211,7 +211,9 @@ struct ConsoleView: View { HStack { Text("系统后台服务").font(.system(size: 11)) Spacer() - Text(model.serviceLoaded ? "运行中" : "未运行").font(.system(size: 11)).foregroundStyle(model.serviceLoaded ? Color.green : Color.orange) + Text(model.serviceLoaded.map { $0 ? "运行中" : "未运行" } ?? "未确认") + .font(.system(size: 11)) + .foregroundStyle(model.serviceLoaded == true ? Color.green : model.serviceLoaded == false ? Color.orange : Color.secondary) } HStack { Text("系统服务开机启动").font(.system(size: 11)) diff --git a/Tests/CoreTests.swift b/Tests/CoreTests.swift index c930937..802c7dc 100644 --- a/Tests/CoreTests.swift +++ b/Tests/CoreTests.swift @@ -4,6 +4,102 @@ import Foundation static func check(_ condition: @autoclosure () -> Bool, _ reason: String) { guard condition() else { fatalError(reason) } } + static func waitForFile(_ url: URL) async throws -> String { + for _ in 0..<400 { + if let text = try? String(contentsOf: url, encoding: .utf8), !text.isEmpty { return text } + try await Task.sleep(nanoseconds: 5_000_000) + } + fatalError("Timed out waiting for subprocess readiness") + } + static func runtimeRegressions() async throws { + let base = ProcessInfo.processInfo.environment["TAIL_CONSOLE_TEST_TEMP"].map { URL(fileURLWithPath: $0) } ?? FileManager.default.temporaryDirectory + let scratch = base.appendingPathComponent("core-tests-" + UUID().uuidString) + try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: scratch) } + + for fd in [1, 2] { + let started = ProcessInfo.processInfo.systemUptime + do { + _ = try await CommandRunner.run("/usr/bin/python3", ["-c", "import os,time; os.write(\(fd),b'x'*(9*1024*1024)); time.sleep(2)"], timeout: 1.5) + fatalError("Oversized output followed by a live process was accepted") + } catch ControlError.outputTooLarge { } + check(ProcessInfo.processInfo.systemUptime - started < 1.0, "Output overflow must stop the command before its deadline, on either pipe") + } + print("PASS: stdout and stderr overflow terminate early with outputTooLarge") + + let boundary = try await CommandRunner.run("/usr/bin/python3", ["-c", "import os; os.write(1,b'x'*(8*1024*1024))"]) + check(boundary.output.count == 8*1024*1024, "Exactly 8 MiB must remain valid") + print("PASS: exact output-cap boundary remains valid") + + let cancelled = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await CommandRunner.run("/nonexistent/should-not-launch", []) + } + do { _ = try await cancelled.value; fatalError("An already-cancelled task launched a command") } + catch is CancellationError { } + print("PASS: cancellation before launch preserves CancellationError") + + let ready = scratch.appendingPathComponent("cancel.pid") + let resistant = "import os,signal,time,pathlib; signal.signal(signal.SIGTERM,signal.SIG_IGN); pathlib.Path(__import__('sys').argv[1]).write_text(str(os.getpid())); time.sleep(3)" + let task = Task { try await CommandRunner.run("/usr/bin/python3", ["-c", resistant, ready.path], timeout: 5) } + let pid = pid_t(try await waitForFile(ready))! + let cancellationStarted = ProcessInfo.processInfo.systemUptime + task.cancel() + do { _ = try await task.value; fatalError("In-flight cancellation was ignored") } + catch is CancellationError { } + check(ProcessInfo.processInfo.systemUptime - cancellationStarted < 1.5, "Cancellation must promptly stop a SIGTERM-resistant command") + check(Darwin.kill(pid, 0) == -1 && errno == ESRCH, "The cancelled command must be reaped before returning") + print("PASS: cancellation stops and reaps a SIGTERM-resistant command") + + let timeoutPID = scratch.appendingPathComponent("timeout.pid") + let timeoutStarted = ProcessInfo.processInfo.systemUptime + do { + _ = try await CommandRunner.run("/usr/bin/python3", ["-c", resistant, timeoutPID.path], timeout: 0.5) + fatalError("SIGTERM resistance bypassed the deadline") + } catch ControlError.timeout { } + let timedPID = pid_t(try await waitForFile(timeoutPID))! + check(ProcessInfo.processInfo.systemUptime - timeoutStarted < 1.5, "Deadline cleanup must have bounded SIGTERM grace") + check(Darwin.kill(timedPID, 0) == -1 && errno == ESRCH, "The timed-out command must be reaped before returning") + print("PASS: deadline escalates SIGTERM-resistant commands to SIGKILL") + + let survivor = scratch.appendingPathComponent("descendant-survived") + let descendantStarted = ProcessInfo.processInfo.systemUptime + let descendant = "import os,signal,time,pathlib,sys\nif os.fork()==0:\n signal.signal(signal.SIGTERM,signal.SIG_IGN);time.sleep(0.9);pathlib.Path(sys.argv[1]).write_text('survived');os._exit(0)\ntime.sleep(0.05);os._exit(0)" + do { + _ = try await CommandRunner.run("/usr/bin/python3", ["-c", descendant, survivor.path], timeout: 0.25) + fatalError("An inherited pipe extended execution beyond the deadline") + } catch ControlError.timeout { } + check(ProcessInfo.processInfo.systemUptime - descendantStarted < 1.0, "Pipe draining must share the command deadline") + let remaining = 1.2 - (ProcessInfo.processInfo.systemUptime - descendantStarted) + if remaining > 0 { try await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) } + check(!FileManager.default.fileExists(atPath: survivor.path), "An isolated command's pipe-holding descendant must not outlive cleanup") + print("PASS: inherited pipes share the deadline and isolated descendants are stopped") + + let initialFDs = try FileManager.default.contentsOfDirectory(atPath: "/dev/fd").count + for _ in 0..<4 { + _ = try await CommandRunner.run("/usr/bin/true", []) + do { _ = try await CommandRunner.run("/bin/sleep", ["2"], timeout: 0.02); fatalError("Expected short timeout") } + catch ControlError.timeout { } + do { _ = try await CommandRunner.run("/nonexistent/tail-console-test", []); fatalError("Expected launch failure") } + catch { check(!(error is CancellationError), "Launch failure changed into cancellation") } + } + let finalFDs = try FileManager.default.contentsOfDirectory(atPath: "/dev/fd").count + check(finalFDs <= initialFDs + 1, "Repeated success, timeout, and failed launch must release pipe descriptors") + print("PASS: repeated command completion and failure release pipe descriptors") + + try await withThrowingTaskGroup(of: Void.self) { group in + for index in 0..<8 { + group.addTask { + let value = "isolated-output-\(index)" + let result = try await CommandRunner.run("/usr/bin/printf", ["%s", value]) + check(result.code == 0 && String(decoding: result.output, as: UTF8.self) == value, "Concurrent commands must retain independent pipes and output") + } + } + try await group.waitForAll() + } + print("PASS: eight concurrent commands retain isolated output") + print("8 subprocess regression groups passed; 0 failed.") + } static func main() async throws { let fixture = #""" {"BackendState":"Running","Self":{"ID":"self","HostName":"my-mac","DNSName":"my-mac.example.ts.net.","TailscaleIPs":["fd7a::1","100.64.0.1"],"Online":true},"Peer":{"a":{"ID":"exit","HostName":"a","DNSName":"exit.example.ts.net.","TailscaleIPs":["100.64.0.2"],"Online":true,"ExitNodeOption":true},"b":{"ID":"offline","HostName":"b","TailscaleIPs":["100.64.0.3"],"Online":false,"ExitNodeOption":true},"c":{"ID":"normal","HostName":"c","TailscaleIPs":["100.64.0.4"],"Online":true}}} @@ -57,5 +153,6 @@ import Foundation do { _ = try await CommandRunner.run("/bin/sleep", ["3"], timeout: 0.1); fatalError("A hung command was not timed out") } catch ControlError.timeout { } print("PASS: decoding, exit eligibility, preference priority, mode transitions, IPv6, argv isolation, concurrent pipe draining, output cap, launch failure, and timeout") + try await runtimeRegressions() } } diff --git a/Tests/MenuTests.swift b/Tests/MenuTests.swift new file mode 100644 index 0000000..394f02b --- /dev/null +++ b/Tests/MenuTests.swift @@ -0,0 +1,119 @@ +import AppKit + +// Exercise real AppKit menu dispatch and text editing without displaying a +// window, launching Tail Console, or touching its daemon or login registration. +@MainActor +private final class TestApplication: NSApplication { + var responderWindow: NSWindow? + override var keyWindow: NSWindow? { responderWindow } + override var mainWindow: NSWindow? { responderWindow } +} + +@MainActor +private final class OpenTarget: NSObject { + var opened = false + @objc func showConsole() { opened = true } +} + +@main +struct MenuTests { + @MainActor + static func main() { + do { try run() } + catch { fputs("MenuTests failed: \(error)\n", stderr); exit(1) } + } + + @MainActor + private static func run() throws { + guard let app = TestApplication.shared as? TestApplication else { fatalError("Test application missing") } + app.setActivationPolicy(.prohibited) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 300, height: 100), + styleMask: [.titled], backing: .buffered, defer: true) + window.isReleasedWhenClosed = false + let editor = NSTextView(frame: window.contentView!.bounds) + editor.isRichText = false + editor.allowsUndo = true + window.contentView = editor + app.responderWindow = window + try check(window.makeFirstResponder(editor), "Hidden text editor becomes first responder") + try check(!window.isVisible, "Test window remains hidden") + + // Preserve all available pasteboard representations in memory. Nothing + // from the pre-existing clipboard is logged or written to disk. + let board = NSPasteboard.general + let savedItems = (board.pasteboardItems ?? []).map { item in + let copy = NSPasteboardItem() + for type in item.types { + if let data = item.data(forType: type) { copy.setData(data, forType: type) } + } + return copy + } + defer { + board.clearContents() + if !savedItems.isEmpty { board.writeObjects(savedItems) } + app.responderWindow = nil + window.close() + } + board.clearContents() + try check(board.setString("fd7a:115c:a1e0::abc", forType: .string), "Fixture is on the clipboard") + + // This negative control reproduces the old menu's missing command. It + // ensures the harness cannot silently pass when Edit is absent. + app.mainMenu = NSMenu() + try check(!press("v", menu: app.mainMenu!), "Missing Edit menu does not consume Cmd-V") + try check(editor.string.isEmpty, "Negative control leaves the editor unchanged") + + let target = OpenTarget() + let menu = ApplicationMenu.make(openConsoleTarget: target, openConsoleAction: #selector(OpenTarget.showConsole)) + app.mainMenu = menu + try check(press("v", menu: menu), "Cmd-V dispatches through the native menu") + try check(editor.string == "fd7a:115c:a1e0::abc", "Cmd-V pastes into the first responder") + editor.breakUndoCoalescing() + editor.undoManager?.removeAllActions() + try check(press("a", menu: menu), "Cmd-A dispatches") + try check(editor.selectedRange() == NSRange(location: 0, length: editor.string.utf16.count), "Cmd-A selects all text") + try check(press("c", menu: menu), "Cmd-C dispatches") + try check(board.string(forType: .string) == editor.string, "Cmd-C copies the selected text") + try check(press("x", menu: menu), "Cmd-X dispatches") + try check(editor.string.isEmpty && board.string(forType: .string) == "fd7a:115c:a1e0::abc", "Cmd-X cuts the selected text") + editor.breakUndoCoalescing() + try check(press("z", menu: menu), "Cmd-Z dispatches") + try check(editor.string == "fd7a:115c:a1e0::abc", "Cmd-Z restores the cut text") + try check(press("z", modifiers: [.command, .shift], menu: menu), "Shift-Cmd-Z dispatches") + try check(editor.string.isEmpty, "Shift-Cmd-Z redoes the cut") + + // Use the field editor backing a native search-style text field too; + // SwiftUI TextField's macOS keyboard actions use this responder path. + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24)) + field.placeholderString = "搜索设备名称或 IP" + window.contentView = field + field.selectText(nil) + try check((window.firstResponder as? NSTextView)?.isFieldEditor == true, "Text field uses a native field editor") + try check(press("v", menu: menu), "Cmd-V dispatches to a text-field editor") + try check(field.currentEditor()?.string == "fd7a:115c:a1e0::abc", "Text field receives the pasted address") + try check(press("a", menu: menu), "Cmd-A dispatches to a text-field editor") + try check(field.currentEditor()?.selectedRange.length == "fd7a:115c:a1e0::abc".utf16.count, "Text field selects the address") + + try check(press("0", menu: menu) && target.opened, "Existing open-console shortcut remains routed to its target") + try check(!window.isVisible, "No test window was displayed") + print("MenuTests: 24 native menu and field-editor checks passed") + } + + @MainActor + private static func press(_ key: String, modifiers: NSEvent.ModifierFlags = [.command], menu: NSMenu) -> Bool { + let characters = modifiers.contains(.shift) ? key.uppercased() : key + guard let event = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: modifiers, + timestamp: 0, windowNumber: 0, context: nil, + characters: characters, charactersIgnoringModifiers: characters, + isARepeat: false, keyCode: 0) else { fatalError("Cannot create key event") } + return menu.performKeyEquivalent(with: event) + } + + private struct CheckFailure: Error, CustomStringConvertible { + let description: String + } + + private static func check(_ condition: @autoclosure () -> Bool, _ message: String) throws { + guard condition() else { throw CheckFailure(description: message) } + } +} diff --git a/Tests/ModelTests.swift b/Tests/ModelTests.swift index 13985b7..5e9b687 100644 --- a/Tests/ModelTests.swift +++ b/Tests/ModelTests.swift @@ -9,16 +9,20 @@ private struct Fixture: Sendable { static func make( backend: String = "Running", wantRunning: Bool = true, routes: [String] = [], exitID: String = "", exitIP: String = "", - lan: Bool = false + lan: Bool = false, gatewayIPs: [String] = ["fd7a::2", "100.64.0.2"], + gatewayOnline: Bool = true, gatewayExitOption: Bool = true, gatewayPresent: Bool = true ) throws -> Fixture { + var peers: [String: Any] = [ + "b": ["ID": "offline", "DNSName": "asleep.example.ts.net.", "TailscaleIPs": ["100.64.0.3"], "Online": false, "ExitNodeOption": true], + "c": ["ID": "normal", "DNSName": "workstation.example.ts.net.", "TailscaleIPs": ["100.64.0.4"], "Online": true, "ExitNodeOption": false] + ] + if gatewayPresent { + peers["a"] = ["ID": "exit-a", "DNSName": "gateway.example.ts.net.", "TailscaleIPs": gatewayIPs, "Online": gatewayOnline, "ExitNodeOption": gatewayExitOption] + } let status: [String: Any] = [ "BackendState": backend, "Self": ["ID": "local", "DNSName": "local.example.ts.net.", "TailscaleIPs": ["100.64.0.1"], "Online": true], - "Peer": [ - "a": ["ID": "exit-a", "DNSName": "gateway.example.ts.net.", "TailscaleIPs": ["fd7a::2", "100.64.0.2"], "Online": true, "ExitNodeOption": true], - "b": ["ID": "offline", "DNSName": "asleep.example.ts.net.", "TailscaleIPs": ["100.64.0.3"], "Online": false, "ExitNodeOption": true], - "c": ["ID": "normal", "DNSName": "workstation.example.ts.net.", "TailscaleIPs": ["100.64.0.4"], "Online": true, "ExitNodeOption": false] - ] + "Peer": peers ] let prefs: [String: Any] = [ "WantRunning": wantRunning, "AdvertiseRoutes": routes, @@ -57,6 +61,10 @@ private actor MockDaemonClient: DaemonClient { private var serviceCalls = 0 private var readDelay: UInt64 = 0 private var actionDelay: UInt64 = 0 + private var readsPaused = false + private var actionsPaused = false + private var readWaiters: [CheckedContinuation] = [] + private var actionWaiters: [CheckedContinuation] = [] private let serviceState: ServiceState init(snapshot: Fixture, service: ServiceState = ServiceState(running: true, bootEnabled: true, keepAlive: true)) { @@ -67,6 +75,18 @@ private actor MockDaemonClient: DaemonClient { func setFault(_ fault: ReadFault?) { self.fault = fault } func setSnapshot(_ snapshot: Fixture) { self.snapshot = snapshot } func setDelays(read: UInt64 = 0, action: UInt64 = 0) { readDelay = read; actionDelay = action } + func pauseReads() { readsPaused = true } + func resumeReads() { + readsPaused = false + let waiters = readWaiters; readWaiters = [] + for waiter in waiters { waiter.resume() } + } + func pauseActions() { actionsPaused = true } + func resumeActions() { + actionsPaused = false + let waiters = actionWaiters; actionWaiters = [] + for waiter in waiters { waiter.resume() } + } func succeedNext(snapshot: Fixture? = nil, postReadFault: ReadFault? = nil) { responses.append(ActionResponse(snapshot: snapshot, error: nil, postReadFault: postReadFault)) } @@ -89,6 +109,7 @@ private actor MockDaemonClient: DaemonClient { // fixture and delayed refreshes can exercise the single-flight guard. let data = arguments.first == "status" ? snapshot.status : snapshot.prefs let currentFault = fault + if readsPaused { await withCheckedContinuation { readWaiters.append($0) } } if readDelay > 0 { try await Task.sleep(nanoseconds: readDelay) } switch (arguments.first, currentFault) { case ("status", .statusTransport?): throw MockFailure(message: "status fixture unavailable") @@ -97,6 +118,7 @@ private actor MockDaemonClient: DaemonClient { default: return data } } + if actionsPaused { await withCheckedContinuation { actionWaiters.append($0) } } if actionDelay > 0 { try await Task.sleep(nanoseconds: actionDelay) } guard !responses.isEmpty else { throw MockFailure(message: "Unexpected unconfigured mutation: \(arguments)") } let response = responses.removeFirst() @@ -159,7 +181,7 @@ private struct ModelTests { await model.refresh() try check(!model.loading && model.fresh && model.connected && model.mayControl, "Successful read must enable controls") try check(model.updatedAt != nil && model.errorMessage == nil, "Successful read must record its time without an error") - try check(model.serviceLoaded && model.serviceBootEnabled == true && model.serviceKeepAlive, "Service state must come from the injected client") + try check(model.serviceLoaded == true && model.serviceBootEnabled == true && model.serviceKeepAlive, "Service state must come from the injected client") try check(model.serviceBootTitle == "开机启动已启用", "Service title must reflect confirmed boot state") await model.refresh() let serviceCount = await client.serviceCount() @@ -169,6 +191,14 @@ private struct ModelTests { try check(calls.filter { $0.arguments == ["debug", "prefs"] }.allSatisfy { !$0.allowFailure }, "Preferences errors must remain errors") } + await test("unverified service diagnostics remain unknown") { + let client = MockDaemonClient(snapshot: try Fixture.make(), service: ServiceState()) + let model = ConsoleModel(client: client, automatic: false) + await model.refresh() + try check(model.fresh && model.connected, "Valid daemon state must remain usable when service diagnostics are unavailable") + try check(model.serviceLoaded == nil && model.serviceBootEnabled == nil && model.serviceBootTitle == "未确认", "Unavailable diagnostics must not become a false stopped or boot-disabled claim") + } + await test("command errors survive both post-operation and subsequent refreshes") { let (model, client) = try await ready() await client.failNext("operator permission denied") @@ -368,6 +398,81 @@ private struct ModelTests { try check(after == before + 5, "The model must perform a separate status/preferences verification after the old refresh and mutation") } + await test("refresh requests during a mutation skip intermediate reads") { + let (model, client) = try await ready() + let before = await client.history().count + await client.pauseActions() + await client.succeedNext(snapshot: try Fixture.make(backend: "Stopped", wantRunning: false)) + model.setConnection(false) + try await eventually("mutation to enter the mock") { await client.mutationHistory().count == 1 } + await model.refresh() + let during = await client.history().count + await client.resumeActions() + try await finished(model) + let after = await client.history().count + try check(during == before + 1, "Incidental refresh must not read partially changing preferences during a mutation") + try check(after == before + 3 && model.notice != nil && !model.connected, "One mandatory readback must follow the mutation") + } + + await test("an operation rechecks freshness after waiting for an old refresh") { + let (model, client) = try await ready() + let before = await client.history().count + await client.setFault(.prefsTransport) + await client.pauseReads() + let oldRefresh = Task { await model.refresh() } + try await eventually("old refresh to pause its reads") { await client.history().count >= before + 2 } + model.setConnection(false) + await client.resumeReads() + await oldRefresh.value + try await finished(model) + let mutations = await client.mutationHistory() + try check(mutations.isEmpty, "A failed pending refresh must revoke permission to issue the queued mutation") + try check(!model.fresh && !model.mayControl && model.readError != nil, "Failed pending refresh must leave stale controls disabled") + } + + await test("pending refresh revokes a selected exit that disappeared or lost eligibility") { + let changedFixtures = [ + try Fixture.make(gatewayPresent: false), + try Fixture.make(gatewayOnline: false), + try Fixture.make(gatewayExitOption: false), + try Fixture.make(gatewayIPs: []) + ] + for changed in changedFixtures { + let (model, client) = try await ready() + let selected = try gateway(model) + let before = await client.history().count + await client.setSnapshot(changed) + await client.pauseReads() + let pending = Task { await model.refresh() } + try await eventually("pending exit-eligibility snapshot") { await client.history().count >= before + 2 } + model.selectExit(selected) + await client.resumeReads() + await pending.value + try await finished(model) + let mutations = await client.mutationHistory() + try check(mutations.isEmpty, "An exit no longer present, online, approved or addressed must fail locally") + try check(model.actionError == ControlError.invalidExit.localizedDescription && model.notice == nil, "A stale exit selection must display its eligibility error") + } + } + + await test("pending refresh updates an exit address before command and verification") { + let (model, client) = try await ready() + let selected = try gateway(model) + let before = await client.history().count + await client.setSnapshot(try Fixture.make(gatewayIPs: ["fd7a::99", "100.64.0.99"])) + await client.pauseReads() + let pending = Task { await model.refresh() } + try await eventually("pending changed exit-address snapshot") { await client.history().count >= before + 2 } + await client.succeedNext(snapshot: try Fixture.make(exitIP: "100.64.0.99", gatewayIPs: ["fd7a::99", "100.64.0.99"])) + model.selectExit(selected) + await client.resumeReads() + await pending.value + try await finished(model) + let mutations = await client.mutationHistory() + try check(mutations == [["set", "--advertise-exit-node=false", "--exit-node=100.64.0.99"]], "A stable device ID must resolve to its latest verified address") + try check(model.currentExit?.id == selected.id && model.actionError == nil && model.notice != nil, "Readback must confirm the newly addressed device rather than the stale selection") + } + await test("hidden 30s, visible 5s, and independent menu/window visibility") { let (model, _) = try await ready() try check(model.pollInterval == 30, "Hidden app must poll every 30 seconds") @@ -385,6 +490,22 @@ private struct ModelTests { try await Task.sleep(nanoseconds: 30_000_000) } + await test("repeated visible notifications do not create extra CLI reads") { + let (model, client) = try await ready() + let before = await client.history().count + model.setVisible("window", true) + try await eventually("first visibility refresh") { await client.history().count >= before + 2 } + await model.refresh() + let afterFirstShow = await client.history().count + model.setVisible("window", true) + model.setVisible("window", true) + // Let any wrongly enqueued tasks reach the actor before asserting. + for _ in 0..<5 { await Task.yield() } + let afterDuplicateShows = await client.history().count + model.setVisible("window", false) + try check(afterDuplicateShows == afterFirstShow, "Duplicate visibility notifications must not issue status/preferences commands") + } + await test("device filtering preserves online count and unresolved exit visibility") { let (model, _) = try await ready(try Fixture.make(exitID: "unknown-exit")) try check(model.onlineCount == 2 && model.peers.count == 2, "Default list must include online peers and count all online peers") @@ -395,6 +516,37 @@ private struct ModelTests { try check(model.currentExit == nil && model.exitTitle == "所选出口不在设备列表中", "An unresolved selected exit must stay visibly selected instead of silently becoming direct internet") } + await test("search includes every dual-stack address without changing ordering or counts") { + let (model, _) = try await ready() + let onlineOrder = model.peers.map(\.id) + for query in ["100.64.0.2", "fd7a::2", "FD7A::2", "GaTeWaY"] { + model.query = query + try check(model.peers.map(\.id) == ["exit-a"], "Query \(query) must find the dual-stack gateway") + try check(model.onlineCount == 2, "Search must preserve the overall online count") + } + model.query = "no-match" + try check(model.peers.isEmpty && model.onlineCount == 2, "Empty results must preserve online count") + model.query = "" + try check(model.peers.map(\.id) == onlineOrder, "Clearing a query must restore the original sorted online peers") + model.onlyOnline = false + let allOrder = model.peers.map(\.id) + try check(allOrder == ["exit-a", "normal", "offline"], "Unfiltered peers must retain online-first name ordering") + model.query = "100.64.0." + try check(model.peers.map(\.id) == allOrder, "Address matches must preserve sorted order") + model.query = "100.64.0.3" + try check(model.peers.map(\.id) == ["offline"], "All-device mode must allow offline address searches") + model.onlyOnline = true + try check(model.peers.isEmpty, "Online-only mode must still exclude a matching offline peer") + } + + await test("IPv6-only addresses remain searchable case-insensitively") { + let (model, _) = try await ready(try Fixture.make(gatewayIPs: ["fd7a::BEEF"])) + for query in ["FD7A::beef", "::BEEF"] { + model.query = query + try check(model.peers.map(\.id) == ["exit-a"], "IPv6-only query must match regardless of hex casing") + } + } + print("\(passed) model tests passed; \(failures.count) failed. All daemon calls used the offline actor mock.") if !failures.isEmpty { throw TestFailure(description: failures.joined(separator: "\n")) } } diff --git a/Tests/ServiceTests.swift b/Tests/ServiceTests.swift new file mode 100644 index 0000000..f52ac22 --- /dev/null +++ b/Tests/ServiceTests.swift @@ -0,0 +1,46 @@ +import Foundation + +@main +struct ServiceTests { + static func main() throws { + let label = "com.tailscale.tailscaled" + func result(_ text: String, code: Int32 = 0) -> ProcessResult { + ProcessResult(code: code, output: Data(text.utf8), error: Data()) + } + func plist(_ values: [String: Any]) throws -> Data { + try PropertyListSerialization.data(fromPropertyList: values, format: .xml, options: 0) + } + func state(_ data: Data?, _ job: ProcessResult?, _ overrides: ProcessResult?) -> ServiceState { + ServiceDiagnostics.state(label: label, plistData: data, job: job, overrides: overrides) + } + func state(_ data: Data?, _ job: ProcessResult?) -> ServiceState { state(data, job, result("system = {}")) } + let running = result("system/job = {\n\tstate = running\n}") + let defined = try plist(["RunAtLoad": true]) + let unreadable = state(nil, running) + precondition(unreadable.running == true && unreadable.bootEnabled == nil, "Unreadable plist must not hide a running job or assert boot configuration") + precondition(state(defined, running).bootEnabled == true) + precondition(state(defined, running, result("\"\(label)\" => disabled")).bootEnabled == false) + let disabled = try plist(["RunAtLoad": true, "Disabled": true]) + precondition(state(disabled, running).bootEnabled == false) + precondition(state(disabled, running, result("\"\(label)\" => enabled")).bootEnabled == true) + precondition(state(defined, running, nil).bootEnabled == nil) + precondition(state(nil, result("permission denied", code: 1)).running == nil) + precondition(state(defined, result("Could not find service \"\(label)\"", code: 113)).running == false) + precondition(state(defined, result("unexpected format")).running == nil) + let waiting = result("job = {\n\tstate = waiting\n\tother = { state = running }\n}") + precondition(state(defined, waiting).running == false) + let conditional = try plist(["KeepAlive": ["NetworkState": true]]) + precondition(state(conditional, running).bootEnabled == nil) + precondition(state(Data("invalid plist".utf8), running).running == true) + precondition(state(Data("invalid plist".utf8), running).bootEnabled == nil) + precondition(state(defined, result("job = {\n\tstate = unfamiliar-future-state\n}")).running == nil) + let stale = ServiceObservation(label: label, definitionExists: true, plistData: nil, job: result("Could not find service", code: 113)) + let brew = ServiceObservation(label: "homebrew.mxcl.tailscale", definitionExists: true, plistData: defined, job: running) + let selected = ServiceDiagnostics.preferred([stale, brew], overrides: result("system = {}")) + precondition(selected.running == true && selected.bootEnabled == true, "A stale first plist must not hide the running Homebrew job") + let loadedWaiting = ServiceObservation(label: "homebrew.mxcl.tailscale", definitionExists: true, plistData: defined, job: waiting) + precondition(ServiceDiagnostics.preferred([stale, loadedWaiting], overrides: nil).running == false) + precondition(ServiceDiagnostics.preferred([], overrides: nil).running == nil) + print("PASS: 17 service-diagnostic checks, including unreadable configuration, stale installation fallback, disabled overrides, unknown responses, and conditional startup") + } +} diff --git a/Tests/test_measure.py b/Tests/test_measure.py new file mode 100644 index 0000000..7b9b4b5 --- /dev/null +++ b/Tests/test_measure.py @@ -0,0 +1,55 @@ +import argparse +import importlib.util +from pathlib import Path +import unittest + +spec = importlib.util.spec_from_file_location('measure', Path(__file__).resolve().parents[1] / 'scripts/measure.py') +measure = importlib.util.module_from_spec(spec) +spec.loader.exec_module(measure) + + +class MeasureTests(unittest.TestCase): + def samples(self): + initial = {name: 0 for name, _ in measure.Usage._fields_ if name != 'uuid'} + initial.update(uuid='same-process', start_time=123, resident_bytes=8 * 2**20, footprint_bytes=4 * 2**20) + final = {**initial, 'user_ns': 1_000_000_000, 'system_ns': 500_000_000, + 'child_user_ns': 250_000_000, 'child_system_ns': 250_000_000, + 'resident_bytes': 2 * 2**20, 'footprint_bytes': 1 * 2**20, + 'disk_read_bytes': 4, 'disk_write_bytes': 8} + return [{'monotonic_seconds': 1, 'usage': initial}, {'monotonic_seconds': 3, 'usage': final}] + + def test_cpu_includes_completed_children_without_hiding_parent_cost(self): + summary = measure.summarize(self.samples()) + self.assertEqual(summary['cpu_seconds'], 1.5) + self.assertEqual(summary['child_cpu_seconds'], 0.5) + self.assertEqual(summary['cpu_percent_one_core'], 75) + self.assertEqual(summary['cpu_percent_one_core_including_children'], 100) + + def test_initial_memory_and_disk_deltas_are_included(self): + summary = measure.summarize(self.samples()) + self.assertEqual((summary['rss_mib_min'], summary['rss_mib_max']), (2, 8)) + self.assertEqual(summary['footprint_mib_max'], 4) + self.assertEqual((summary['disk_read_delta_bytes'], summary['disk_write_delta_bytes']), (4, 8)) + + def test_changed_process_identity_is_rejected(self): + for key, value in [('uuid', 'other'), ('start_time', 124)]: + samples = self.samples() + samples[-1]['usage'][key] = value + with self.assertRaises(RuntimeError): measure.summarize(samples) + + def test_counter_reset_and_zero_duration_are_rejected(self): + samples = self.samples() + samples[0]['usage']['disk_write_bytes'] = 100 + with self.assertRaises(RuntimeError): measure.summarize(samples) + samples = self.samples() + samples[-1]['monotonic_seconds'] = samples[0]['monotonic_seconds'] + with self.assertRaises(RuntimeError): measure.summarize(samples) + + def test_invalid_durations_and_pids_are_rejected(self): + for value in ['0', '-1', 'nan', 'inf', '-inf']: + with self.assertRaises(argparse.ArgumentTypeError): measure.positive_number(value) + for value in ['0', '-1', str(2**31)]: + with self.assertRaises(argparse.ArgumentTypeError): measure.positive_pid(value) + + +if __name__ == '__main__': unittest.main() diff --git a/scripts/build.sh b/scripts/build.sh index 69606ff..77a9875 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,7 +1,10 @@ #!/bin/zsh set -euo pipefail TASK_ROOT="${0:A:h:h}" -APP="$TASK_ROOT/build/Tail Console.app" +mkdir -p "$TASK_ROOT/build" +PACKAGE_WORK="$(mktemp -d "$TASK_ROOT/build/.package.XXXXXX")" +trap 'rm -rf -- "$PACKAGE_WORK"' EXIT +APP="$PACKAGE_WORK/Tail Console.app" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$TASK_ROOT/build/module-cache" xcrun swiftc -swift-version 5 -O -parse-as-library -target arm64-apple-macosx14.0 \ -module-cache-path "$TASK_ROOT/build/module-cache" \ @@ -30,8 +33,8 @@ info = { 'CFBundleExecutable': 'TailConsole', 'CFBundleName': 'Tail Console', 'CFBundleDisplayName': 'Tail Console', - 'CFBundleShortVersionString': '1.1.0', - 'CFBundleVersion': '2', + 'CFBundleShortVersionString': '1.1.1', + 'CFBundleVersion': '3', 'CFBundlePackageType': 'APPL', 'CFBundleIconFile': 'AppIcon', 'LSMinimumSystemVersion': '14.0', @@ -46,7 +49,15 @@ mkdir -p "$APP/Contents/Resources/Project/Sources" "$APP/Contents/Resources/Proj cp "$TASK_ROOT"/Sources/*.swift "$APP/Contents/Resources/Project/Sources/" cp "$TASK_ROOT"/scripts/* "$APP/Contents/Resources/Project/scripts/" cp "$TASK_ROOT"/Tests/*.swift "$APP/Contents/Resources/Project/Tests/" +cp "$TASK_ROOT"/Tests/test_measure.py "$APP/Contents/Resources/Project/Tests/" cp "$TASK_ROOT/README.md" "$APP/Contents/Resources/Project/" +cp "$TASK_ROOT/DEVELOPMENT.md" "$APP/Contents/Resources/Project/" +cp "$TASK_ROOT/REVIEW.md" "$APP/Contents/Resources/Project/" codesign --force --deep --sign - "$APP" codesign --verify --deep --strict "$APP" -print -r -- "$APP" +FINAL_APP="$TASK_ROOT/build/Tail Console.app" +# Assemble a fresh bundle so removed or renamed source files cannot survive a +# rebuild. Replace the previous generated bundle only after signature checks. +rm -rf -- "$FINAL_APP" +mv "$APP" "$FINAL_APP" +print -r -- "$FINAL_APP" diff --git a/scripts/measure.py b/scripts/measure.py index b868fbc..b8da80a 100644 --- a/scripts/measure.py +++ b/scripts/measure.py @@ -1,14 +1,23 @@ -"""Read-only macOS process sampling. Does not change daemon preferences.""" +"""Read-only macOS process sampling; never changes daemon preferences. + +CPU comes from cumulative kernel counters, including completed child work between +samples. Child completion accounting can span the requested window's boundaries. +RSS, footprint and disk I/O describe the selected parent process only. +""" import argparse import ctypes +from datetime import datetime, timezone import json +import math import os +from pathlib import Path import subprocess +import sys import time -from pathlib import Path class Usage(ctypes.Structure): + # ABI: struct rusage_info_v2 in the macOS SDK's sys/resource.h. _fields_ = [('uuid', ctypes.c_ubyte * 16)] + [(name, ctypes.c_uint64) for name in [ 'user_ns', 'system_ns', 'package_wakeups', 'interrupt_wakeups', 'pageins', 'wired_bytes', 'resident_bytes', 'footprint_bytes', 'start_time', 'exit_time', @@ -16,46 +25,149 @@ class Usage(ctypes.Structure): 'child_pageins', 'child_elapsed', 'disk_read_bytes', 'disk_write_bytes']] -parser = argparse.ArgumentParser() -parser.add_argument('--seconds', type=int, default=45) -parser.add_argument('--label', required=True) -parser.add_argument('--output', required=True) -args = parser.parse_args() -lib = ctypes.CDLL('/usr/lib/libproc.dylib') -lib.proc_pid_rusage.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_void_p] -lib.proc_pid_rusage.restype = ctypes.c_int -pids = {name: int(subprocess.check_output(['/usr/bin/pgrep', '-x', name]).splitlines()[0]) - for name in ['TailConsole']} - - -def read(pid): - usage = Usage() - if lib.proc_pid_rusage(pid, 2, ctypes.byref(usage)) != 0: - raise RuntimeError('Cannot sample process ' + str(pid)) - return {name: getattr(usage, name) for name, _ in Usage._fields_ if name != 'uuid'} - - -first = {name: read(pid) for name, pid in pids.items()} -start = time.monotonic() -samples = [] -while time.monotonic() - start < args.seconds: - time.sleep(min(1, args.seconds - (time.monotonic() - start))) - samples.append({name: read(pid) for name, pid in pids.items()}) -elapsed = time.monotonic() - start -report = {'label': args.label, 'elapsed_seconds': elapsed, 'logical_cpus': os.cpu_count(), 'processes': {}} -for name in pids: - last = samples[-1][name] - initial = first[name] - cpu = ((last['user_ns'] + last['system_ns']) - (initial['user_ns'] + initial['system_ns'])) / 1e9 - report['processes'][name] = { - 'pid': pids[name], - 'cpu_seconds': cpu, - 'cpu_percent_one_core': 100 * cpu / elapsed, - 'rss_mib_min': min(s[name]['resident_bytes'] for s in samples) / 2**20, - 'rss_mib_max': max(s[name]['resident_bytes'] for s in samples) / 2**20, - 'footprint_mib_max': max(s[name]['footprint_bytes'] for s in samples) / 2**20, - 'disk_write_delta_bytes': last['disk_write_bytes'] - initial['disk_write_bytes'], - 'child_cpu_seconds': ((last['child_user_ns'] + last['child_system_ns']) - (initial['child_user_ns'] + initial['child_system_ns'])) / 1e9, +def positive_number(value): + number = float(value) + if not math.isfinite(number) or number <= 0: + raise argparse.ArgumentTypeError('must be finite and greater than zero') + return number + + +def positive_pid(value): + pid = int(value) + if not 0 < pid <= 2**31 - 1: + raise argparse.ArgumentTypeError('must be a positive PID') + return pid + + +def summarize(samples): + first, last = samples[0], samples[-1] + elapsed = last['monotonic_seconds'] - first['monotonic_seconds'] + if elapsed <= 0: + raise RuntimeError('Sampling elapsed time must be positive') + initial, final = first['usage'], last['usage'] + if any(sample['usage']['start_time'] != initial['start_time'] or + sample['usage']['uuid'] != initial['uuid'] for sample in samples): + raise RuntimeError('Selected PID was reused or its executable changed during sampling') + + def delta(*fields): + changes = [final[field] - initial[field] for field in fields] + if any(change < 0 for change in changes): + raise RuntimeError('A cumulative counter decreased during sampling: ' + ', '.join(fields)) + return sum(changes) + + parent_cpu = delta('user_ns', 'system_ns') / 1e9 + child_cpu = delta('child_user_ns', 'child_system_ns') / 1e9 + return { + 'cpu_seconds': parent_cpu, + 'child_cpu_seconds': child_cpu, + 'cpu_seconds_including_children': parent_cpu + child_cpu, + 'cpu_percent_one_core': 100 * parent_cpu / elapsed, + 'cpu_percent_one_core_including_children': 100 * (parent_cpu + child_cpu) / elapsed, + 'rss_mib_min': min(sample['usage']['resident_bytes'] for sample in samples) / 2**20, + 'rss_mib_max': max(sample['usage']['resident_bytes'] for sample in samples) / 2**20, + 'footprint_mib_min': min(sample['usage']['footprint_bytes'] for sample in samples) / 2**20, + 'footprint_mib_max': max(sample['usage']['footprint_bytes'] for sample in samples) / 2**20, + 'disk_read_delta_bytes': delta('disk_read_bytes'), + 'disk_write_delta_bytes': delta('disk_write_bytes'), + 'package_wakeups_delta': delta('package_wakeups'), + 'interrupt_wakeups_delta': delta('interrupt_wakeups'), + 'child_package_wakeups_delta': delta('child_package_wakeups'), + 'child_interrupt_wakeups_delta': delta('child_interrupt_wakeups'), } -Path(args.output).write_text(json.dumps(report, indent=2) + '\n') -print(json.dumps(report, indent=2), flush=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--pid', type=positive_pid, required=True, + help='Exact process to measure; never picks an arbitrary name match') + parser.add_argument('--expect-path', type=Path, + help='Abort unless this is the selected executable path') + parser.add_argument('--seconds', type=positive_number, default=45) + parser.add_argument('--interval', type=positive_number, default=1) + parser.add_argument('--label', required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + if sys.platform != 'darwin': + parser.error('proc_pid_rusage sampling requires macOS') + + try: + lib = ctypes.CDLL('/usr/lib/libproc.dylib', use_errno=True) + lib.proc_pid_rusage.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_void_p] + lib.proc_pid_rusage.restype = ctypes.c_int + lib.proc_pidpath.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32] + lib.proc_pidpath.restype = ctypes.c_int + + path_buffer = ctypes.create_string_buffer(4096) + if lib.proc_pidpath(args.pid, path_buffer, len(path_buffer)) <= 0: + raise OSError(ctypes.get_errno(), 'Cannot inspect selected PID ' + str(args.pid)) + executable = Path(os.fsdecode(path_buffer.value)) + if args.expect_path and executable.resolve() != args.expect_path.resolve(): + raise RuntimeError('Selected executable does not match --expect-path: ' + str(executable)) + + def sample(): + usage = Usage() + before = time.monotonic() + if lib.proc_pid_rusage(args.pid, 2, ctypes.byref(usage)) != 0: + raise OSError(ctypes.get_errno(), 'Selected process exited or cannot be sampled: ' + str(args.pid)) + after = time.monotonic() + values = {name: getattr(usage, name) for name, _ in Usage._fields_ if name != 'uuid'} + values['uuid'] = bytes(usage.uuid).hex() + return {'monotonic_seconds': (before + after) / 2, 'usage': values} + + def boundary_children(): + result = subprocess.run(['/usr/bin/pgrep', '-P', str(args.pid)], capture_output=True, text=True) + if result.returncode not in (0, 1): + raise RuntimeError('Cannot inspect child-process boundary: ' + result.stderr.strip()) + return sorted(int(pid) for pid in result.stdout.split()) + + started_at = datetime.now(timezone.utc).isoformat() + children_start = boundary_children() + samples = [sample()] + start = samples[0]['monotonic_seconds'] + deadline = start + args.seconds + next_sample = min(start + args.interval, deadline) + while True: + # Recompute the remaining duration once per sleep to avoid a negative + # sleep when the deadline falls between two clock reads. + remaining = next_sample - time.monotonic() + if remaining > 0: + time.sleep(remaining) + samples.append(sample()) + if samples[-1]['monotonic_seconds'] >= deadline: + break + next_sample = min(next_sample + args.interval, deadline) + + children_end = boundary_children() + ended_at = datetime.now(timezone.utc).isoformat() + metrics = summarize(samples) + metrics.update({ + 'pid': args.pid, 'executable': str(executable), 'sample_count': len(samples), + 'child_pids_near_start': children_start, 'child_pids_near_end': children_end, + }) + report = { + 'schema_version': 2, + 'label': args.label, + 'started_at_utc': started_at, + 'ended_at_utc': ended_at, + 'requested_seconds': args.seconds, + 'sample_interval_seconds': args.interval, + 'elapsed_seconds': samples[-1]['monotonic_seconds'] - start, + 'logical_cpus': os.cpu_count(), + 'scope': { + 'cpu': 'Parent cumulative CPU plus kernel-reported completed child CPU deltas. A child completed in-window can include CPU from before the first sample; work in children still running at the last sample is excluded.', + 'child_boundary': 'Child PID lists are near-boundary observations, not an atomic census. Prefer quiet boundaries with no live query children; nonempty lists flag possible boundary bias.', + 'cpu_percent': 'Percent of one logical core; 100% means one fully occupied core.', + 'memory_and_disk': 'Parent process only; excludes child memory/disk. RSS includes shared pages; footprint is the kernel charged footprint.', + 'sampling': 'Memory extrema are sampled observations, not continuous high-water marks. Initial sample is included.', + }, + 'processes': {executable.name: metrics}, + 'samples': [{**entry, 'seconds': entry['monotonic_seconds'] - start} for entry in samples], + } + args.output.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps({key: value for key, value in report.items() if key != 'samples'}, indent=2), flush=True) + except (OSError, RuntimeError) as error: + parser.exit(2, 'Sampling failed: ' + str(error) + '\n') + + +if __name__ == '__main__': + main() diff --git a/scripts/menu-test.sh b/scripts/menu-test.sh new file mode 100755 index 0000000..7ff906a --- /dev/null +++ b/scripts/menu-test.sh @@ -0,0 +1,11 @@ +#!/bin/zsh +set -euo pipefail +TASK_ROOT="${0:A:h:h}" +mkdir -p "$TASK_ROOT/build/module-cache" +xcrun swiftc -swift-version 5 -parse-as-library \ + -module-cache-path "$TASK_ROOT/build/module-cache" \ + -framework AppKit \ + "$TASK_ROOT/Sources/ApplicationMenu.swift" \ + "$TASK_ROOT/Tests/MenuTests.swift" \ + -o "$TASK_ROOT/build/MenuTests" +"$TASK_ROOT/build/MenuTests" diff --git a/scripts/service-test.sh b/scripts/service-test.sh new file mode 100755 index 0000000..a8d7806 --- /dev/null +++ b/scripts/service-test.sh @@ -0,0 +1,9 @@ +#!/bin/zsh +set -euo pipefail +TASK_ROOT="${0:A:h:h}" +mkdir -p "$TASK_ROOT/build/module-cache" +xcrun swiftc -swift-version 5 -parse-as-library \ + -module-cache-path "$TASK_ROOT/build/module-cache" \ + "$TASK_ROOT/Sources/Core.swift" "$TASK_ROOT/Sources/DaemonClient.swift" \ + "$TASK_ROOT/Tests/ServiceTests.swift" -o "$TASK_ROOT/build/ServiceTests" +"$TASK_ROOT/build/ServiceTests" diff --git a/scripts/test-all.sh b/scripts/test-all.sh new file mode 100755 index 0000000..facfd9d --- /dev/null +++ b/scripts/test-all.sh @@ -0,0 +1,8 @@ +#!/bin/zsh +set -euo pipefail +TASK_ROOT="${0:A:h:h}" +"$TASK_ROOT/scripts/test.sh" +"$TASK_ROOT/scripts/model-test.sh" +"$TASK_ROOT/scripts/menu-test.sh" +"$TASK_ROOT/scripts/service-test.sh" +/usr/bin/python3 -B -m unittest discover -s "$TASK_ROOT/Tests" -p 'test_measure.py' -v