From 1a2241745125c354e666a8e8c51b4d93957cfa0a Mon Sep 17 00:00:00 2001 From: zJay26 <120452926+zJay26@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:36:55 +0800 Subject: [PATCH] fix: handle macOS exit races and gate v2.6.1 on native checks --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 19 ++++++++++ docs/macos.md | 2 + docs/releases/v2.6.0.md | 2 + docs/releases/v2.6.1.md | 36 ++++++++++++++++++ internal/app/app.go | 2 +- internal/platform/platform_darwin.go | 46 ++++++++++++++++++----- internal/platform/platform_darwin_test.go | 39 +++++++++++++++++++ internal/platform/update_darwin.go | 1 + scripts/build.ps1 | 2 +- scripts/build.sh | 2 +- scripts/demo-api.js | 4 +- scripts/mock-dashboard.mjs | 2 +- 13 files changed, 143 insertions(+), 16 deletions(-) create mode 100644 docs/releases/v2.6.1.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1033c1e..5cba16d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: run: go test -race ./internal/usage ./internal/store ./internal/server - name: Native macOS install, restart and uninstall if: runner.os == 'macOS' - run: bash scripts/test-macos.sh + run: for attempt in 1 2 3; do bash scripts/test-macos.sh; done cross-build: name: cross-build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28aa2e1..d0c7fb6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,26 @@ permissions: contents: write jobs: + macos: + name: release macOS (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [macos-latest, macos-15-intel] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: "1.26.8" + cache: true + - run: go test ./internal/platform ./internal/app + - name: Repeated native service lifecycle checks + run: for attempt in 1 2 3; do bash scripts/test-macos.sh; done + release: + needs: macos runs-on: ubuntu-latest timeout-minutes: 20 steps: diff --git a/docs/macos.md b/docs/macos.md index b8f7fbb..ba20d8e 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -32,6 +32,8 @@ Default locations: Uninstall unloads the agent and removes the installed executable, retaining the database. `uninstall --purge` additionally deletes only a validated, marked state directory. Optional in-app updates use a separate launchd helper so stopping the main agent does not kill the updater. Downloads are checked against SHA256SUMS; program and data backups are retained, with startup-failure rollback as on Windows/Linux. +After launchd stops its job, the application waits for the original process to exit. An exited zombie or a PID now owned by another process is treated as the old process having ended; no additional signal is sent to a reused PID. Native CI and release checks repeat installation, uninstall and reinstall three times on both architectures. + These command-line binaries do not have an Apple Developer ID signature or Apple notarization. If macOS blocks a browser-downloaded binary, verify the checksum and follow [Apple's instructions for opening a trusted app](https://support.apple.com/en-us/102445). Do not disable Gatekeeper globally. Native CI covers macOS Intel and Apple Silicon; this is not a notarized `.app`/`.pkg` distribution. The service follows [Apple's LaunchAgent model](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html). macOS includes `launchctl` for loading and unloading these jobs, as described in [Apple's Terminal guide](https://support.apple.com/guide/terminal/script-management-with-launchd-apdc6c1077b-5d5d-4d35-9c19-60f2397b2369/mac). diff --git a/docs/releases/v2.6.0.md b/docs/releases/v2.6.0.md index 88dd8ca..d122690 100644 --- a/docs/releases/v2.6.0.md +++ b/docs/releases/v2.6.0.md @@ -1,5 +1,7 @@ # codex-usage v2.6.0 +此版本保留为预发布记录:合并后的重复检查发现 macOS Intel 存在退出时序问题,可能导致卸载/更新误报 PID 不匹配。请使用修复此问题的 v2.6.1 或后续稳定版。 + 修复计量与查询一致性,新增主任务/子任务树及 macOS 安装分发。 - **Turn 累计计量**:区分 Session 与 Turn 累计,新 Turn 数值小于、等于或大于上一 Turn 都可正确计量;保留旧格式、fork 排重、模式分类和分类修正。 diff --git a/docs/releases/v2.6.1.md b/docs/releases/v2.6.1.md new file mode 100644 index 0000000..8a045cd --- /dev/null +++ b/docs/releases/v2.6.1.md @@ -0,0 +1,36 @@ +# codex-usage v2.6.1 + +修复计量与查询一致性,新增主任务/子任务树及 macOS 安装分发。 + +此稳定版包含 v2.6.0 的全部功能,并修复原生重复检查发现的 macOS 退出时序问题:launchd bootout 后等待原进程退出,将僵尸进程和已被复用的 PID 视为原进程已结束,避免再次向该 PID 发送信号或误报安装路径不匹配。CI 和 Release 均在两个 macOS 架构上重复执行三轮安装、卸载和重装;发布必须等待原生检查通过。 + +- **Turn 累计计量**:区分 Session 与 Turn 累计,新 Turn 数值小于、等于或大于上一 Turn 都可正确计量;保留旧格式、fork 排重、模式分类和分类修正。 +- **可靠入账**:事件、模式、累计进度和文件游标原子提交;存储错误回滚并保留重试位置,半行 JSONL 不会被提前跳过。 +- **跨进程刷新**:数据库版本号随事务持久化;任务与费用使用一致快照,本机价格配置变化也能使缓存失效。 +- **搜索与查询**:搜索确定任务后,用相同筛选范围计算 Token 与费用;先选任务、聚合事件再关联元数据。相同 114,960 条事件快照的三轮本机比较,列表中位耗时 2.75 s → 0.43 s,任务费用接口 3.12 s → 0.83 s(均未命中应用缓存)。 +- **统一时区**:计量时区保存在数据库并显示在页面底部;异地浏览器按服务端日期和真实小时边界显示,支持夏令时重复小时与 23/25 小时日期。 +- **任务树**:明细支持列表/树切换、折叠、根任务分页、本任务及含子任务用量;仅使用明确的父子元数据,fork 来源单列,缺失父任务或循环关系会明确提示。 +- **macOS**:新增 Apple Silicon / Intel 单文件程序、用户 LaunchAgent 安装卸载、浏览器打开及可选更新;原有 Windows / Linux 与 Fast 费用功能保留。 +- 发布构建更新为 Go 1.26.8,产物为六个平台程序与 `SHA256SUMS`。中英文、浅深色、窄屏和合成 Demo 同步更新。 + +**历史数据保留:** 升级不会自动重算旧账。本次计量修复对新增记录生效;若要修正旧版本已经漏计的历史,需要先备份并核对源 JSONL 覆盖,再显式运行 `codex-usage scan --rebuild`。源文件已删除的历史不能靠重建恢复。旧 Session 出现在新的物理文件且无法验证排重边界时,会请求确认重建。 + +macOS 程序未经 Apple Developer ID 签名/公证;请核验校验和,参见 [macOS 安装说明](https://github.com/zJay26/codex-usage/blob/v2.6.1/docs/macos.md)。无需也不建议全局关闭 Gatekeeper。 + +详见 [计量、任务树与验证说明](https://github.com/zJay26/codex-usage/blob/v2.6.1/docs/accounting-v2.6.md)。现有 v2.5.0 安装可从“软件更新”选择升级。 + +--- + +Fix accounting and query consistency; add task trees and native macOS installation. + +This stable release includes all v2.6.0 features and fixes a macOS shutdown race found by repeated native checks. After launchd bootout, wait for the original process to exit; treat zombies or a reused PID as the original process having ended, without signalling the new PID owner. CI and release gates repeat the install/uninstall/reinstall cycle three times on both Mac architectures. + +- Distinguish session and turn cumulative counters, including smaller/equal/larger first snapshots; preserve legacy writers, fork deduplication, Fast attribution and classification corrections. +- Commit events, modes, progress and file offsets together. Database failures roll back and retry; incomplete JSONL prefixes remain unread until complete. +- Persist cross-process data revisions and use consistent session/cost snapshots. External pricing changes invalidate caches. Search and costs use the same filtered session scope. +- Aggregate only paged sessions before joining metadata. Three local cache-miss runs on the same 114,960-event snapshot reduced median session-list latency from 2.75 s to 0.43 s and session-cost latency from 3.12 s to 0.83 s. +- Persist the accounting time zone; use real hour identities for remote browsers, DST repeated hours and 23/25-hour days. +- Add collapsible, paginated task trees with own and subtree usage, explicit parent metadata, separate fork lineage and visible orphan/cycle handling. +- Add macOS Apple Silicon / Intel binaries, per-user LaunchAgents, uninstall, browser opening and optional updates. Build six platform binaries and checksums with Go 1.26.8. + +**History stays intact.** New accounting applies to newly read records. Historical recalculation requires an explicit `scan --rebuild` after verifying retained JSONL coverage and backing up the state; deleted sources cannot be recovered by rebuilding. macOS binaries are not Developer-ID signed or notarized. See the linked installation and accounting notes for contracts and validation boundaries. diff --git a/internal/app/app.go b/internal/app/app.go index 733318f..02b45c6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -31,7 +31,7 @@ import ( ) var ( - Version = "2.6.0" + Version = "2.6.1" Commit = "dev" BuildDate = "unknown" ) diff --git a/internal/platform/platform_darwin.go b/internal/platform/platform_darwin.go index 2eed76f..0d16cb2 100644 --- a/internal/platform/platform_darwin.go +++ b/internal/platform/platform_darwin.go @@ -150,24 +150,52 @@ func stopDarwinProcess(pid int, executable string) error { if pid <= 0 || pid == os.Getpid() { return nil } - output, err := exec.Command("/bin/ps", "-p", strconv.Itoa(pid), "-o", "comm=").Output() - if err != nil { - if syscall.Kill(pid, 0) == syscall.ESRCH { - return nil - } + matches, live, err := darwinProcessIdentity(pid, executable) + if err != nil || !live { return err } - actual := strings.TrimSpace(string(output)) - expected, _ := filepath.EvalSymlinks(executable) - if actual != executable && (expected == "" || actual != expected) { + if !matches { return fmt.Errorf("service PID does not match installed application") } if err = syscall.Kill(pid, syscall.SIGTERM); err != nil && err != syscall.ESRCH { return err } + return waitDarwinProcessExit(pid, executable) +} + +func darwinProcessIdentity(pid int, executable string) (matches, live bool, err error) { + output, err := exec.Command("/bin/ps", "-p", strconv.Itoa(pid), "-o", "stat=", "-o", "comm=").Output() + if err != nil { + if syscall.Kill(pid, 0) == syscall.ESRCH { + return false, false, nil + } + return false, false, err + } + fields := strings.Fields(string(output)) + if len(fields) < 2 || strings.HasPrefix(fields[0], "Z") { + // An exited launchd child can remain briefly as a zombie. Its command + // path is no longer reliable, and it must not be signalled again. + return false, false, nil + } + actual := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(output)), fields[0])) + expected, _ := filepath.EvalSymlinks(executable) + return actual == executable || (expected != "" && actual == expected), true, nil +} + +// Call only after the matching process was signalled or its launchd job was +// booted out. A changed identity means the original PID has been reaped/reused; +// never send a signal to the new occupant of that PID. +func waitDarwinProcessExit(pid int, executable string) error { + if pid <= 0 || pid == os.Getpid() { + return nil + } deadline := time.Now().Add(20 * time.Second) for time.Now().Before(deadline) { - if syscall.Kill(pid, 0) == syscall.ESRCH { + matches, live, err := darwinProcessIdentity(pid, executable) + if err != nil { + return err + } + if !live || !matches { return nil } time.Sleep(100 * time.Millisecond) diff --git a/internal/platform/platform_darwin_test.go b/internal/platform/platform_darwin_test.go index 4d36b91..f654da4 100644 --- a/internal/platform/platform_darwin_test.go +++ b/internal/platform/platform_darwin_test.go @@ -5,7 +5,9 @@ import ( "os/exec" "path/filepath" "strings" + "syscall" "testing" + "time" ) func TestLaunchAgentEscapesPathsAndSeparatesUpdateHelper(t *testing.T) { @@ -44,3 +46,40 @@ func TestDarwinStopRejectsAnotherExecutable(t *testing.T) { t.Fatal("unrelated process was not rejected") } } + +func TestDarwinStopAcceptsExitedChildBeforeReaping(t *testing.T) { + cmd := exec.Command("/bin/sh", "-c", "exit 0") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cmd.Wait() }) + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + _, live, err := darwinProcessIdentity(cmd.Process.Pid, "/bin/sh") + if err != nil { + t.Fatal(err) + } + if !live { + if err := stopDarwinProcess(cmd.Process.Pid, "/bin/sh"); err != nil { + t.Fatal(err) + } + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("child did not exit") +} + +func TestDarwinWaitDoesNotSignalReusedPID(t *testing.T) { + cmd := exec.Command("/bin/sleep", "30") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) + if err := waitDarwinProcessExit(cmd.Process.Pid, "/tmp/previous/codex-usage"); err != nil { + t.Fatal(err) + } + if err := syscall.Kill(cmd.Process.Pid, 0); err != nil { + t.Fatal("replacement process was signalled", err) + } +} diff --git a/internal/platform/update_darwin.go b/internal/platform/update_darwin.go index 4b73d72..f2672a6 100644 --- a/internal/platform/update_darwin.go +++ b/internal/platform/update_darwin.go @@ -32,6 +32,7 @@ func StopForUpdate(executable, stateDir string, managed bool) error { if err := launchctl("bootout", target); err != nil { return err } + return waitDarwinProcessExit(pid, executable) } } return stopDarwinProcess(pid, executable) diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 95997dc..2cb3b09 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "2.6.0" + [string]$Version = "2.6.1" ) $ErrorActionPreference = "Stop" diff --git a/scripts/build.sh b/scripts/build.sh index 5130013..79314c9 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -3,7 +3,7 @@ set -euo pipefail project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" dist="$project_root/dist" -version="${VERSION:-2.6.0}" +version="${VERSION:-2.6.1}" build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)" commit="$(git -C "$project_root" rev-parse --short HEAD 2>/dev/null || printf source)" if git -C "$project_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 && diff --git a/scripts/demo-api.js b/scripts/demo-api.js index 2201444..133f17a 100644 --- a/scripts/demo-api.js +++ b/scripts/demo-api.js @@ -300,7 +300,7 @@ }; } - const demoUpdates = { current_version: "2.6.0-demo", latest_version: "2.6.0", release_url: "https://github.com/zJay26/codex-usage/releases", auto_check: true, available: false, can_install: false, phase: "idle" }; + const demoUpdates = { current_version: "2.6.1-demo", latest_version: "2.6.1", release_url: "https://github.com/zJay26/codex-usage/releases", auto_check: true, available: false, can_install: false, phase: "idle" }; async function syntheticFetch(input, init = {}) { const raw = typeof input === "string" ? input : input.url; const url = new URL(raw, root.location.href); @@ -313,7 +313,7 @@ return jsonResponse(demoUpdates); } if (endpoint === "/api/v1/status") return jsonResponse({ - version: "2.6.0-demo", scanning: false, + version: "2.6.1-demo", scanning: false, status: { machine: { id: "synthetic-machine", label: "Synthetic Windows · demo", hostname: "synthetic-host", os: "windows", arch: "amd64" }, last_scan: now.toISOString(), accounting_mode: "jsonl_only", otel_active: false, diff --git a/scripts/mock-dashboard.mjs b/scripts/mock-dashboard.mjs index 00e90ce..d2fa26d 100644 --- a/scripts/mock-dashboard.mjs +++ b/scripts/mock-dashboard.mjs @@ -86,7 +86,7 @@ function costEstimate(url) { const server = http.createServer(async (request, response) => { const url = new URL(request.url, `http://127.0.0.1:${port}`); if (url.pathname === "/api/v1/status") return json(response, { - version: "2.6.0-preview", scanning: false, + version: "2.6.1-preview", scanning: false, status: { machine: { id: "62c0172d-36c4-4ec9-a074-02b9ec2b45e1", label: "WORKSTATION-19 · windows", hostname: "WORKSTATION-19", os: "windows", arch: "amd64" }, last_scan: now.toISOString(), accounting_mode: "jsonl_only", otel_active: false,