diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a0f2eb8f9..f5dc54bee0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -290,9 +290,9 @@ jobs: native_desktop: name: Native desktop - ${{ matrix.label }} runs-on: ${{ matrix.runner }} - # Host tests, the optimized example (with dev features), and the native - # bundle have distinct Cargo builds. Budget the complete cold sequence; - # Intel needs additional headroom without shortening any validation phase. + # Host tests and the native bundle are distinct Cargo builds of the desktop + # and server graph. Budget the complete cold sequence without shortening + # any validation phase. timeout-minutes: ${{ matrix.jobTimeoutMinutes }} strategy: fail-fast: false @@ -388,18 +388,15 @@ jobs: - name: Test desktop Rust host run: vp run --filter @bibcode/desktop test - # The release symbol profile, as the bundle build below uses, so the - # dependencies both builds share compile once instead of once per - # profile. Strip and debug info do not change the unwinding it probes. + # A crate of its own with the desktop graph's objc2 features, under the + # shipped release profile. As a bibcode-desktop example it compiled the + # whole desktop and server graph a second time with test-only features: + # 84 of the 215 minutes of the macOS arm64 row on 2026-11-13. - name: Verify macOS release exception recovery if: matrix.platform == 'mac' - env: - CARGO_PROFILE_RELEASE_STRIP: none - CARGO_PROFILE_RELEASE_DEBUG: line-tables-only - CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO: packed run: | rust_target="$(rustc -Vv | sed -n 's/^host: //p')" - cargo run --release --target "$rust_target" -p bibcode-desktop --example objc_exception_probe + cargo run --release --target "$rust_target" -p bibcode-objc-exception-probe - name: Test Windows desktop E2E support contracts if: matrix.platform == 'win' @@ -424,14 +421,6 @@ jobs: if: matrix.platform == 'win' run: node scripts/run-msvc.mjs cargo test -p bibcode-server --test windows_terminal_shims -j 2 -- review_prompt_shim_ - - name: Run Git system configuration tests (macOS) - if: matrix.platform == 'mac' - run: cargo test -p bibcode-server --test git_system_config -j 2 - - - name: Run background Git index lock tests (macOS) - if: matrix.platform == 'mac' - run: cargo test -p bibcode-server --lib -j 2 -- background_status_ git_stop_ index_lock_cleanup_ - # #292: the host collector, its exit with a disk read held, and its runtime start and # quiesce. Each command fails the step when its filter matches no test. - name: Run host metrics tests (Windows) @@ -455,27 +444,6 @@ jobs: counted exactly-one node scripts/run-msvc.mjs cargo test -p bibcode-server --test host_metrics_exit -j 2 -- --exact a_held_disk_read_does_not_block_exit counted exactly-one node scripts/run-msvc.mjs cargo test -p bibcode-server --lib -j 2 -- --exact production::runtime::tests::host_metrics_start_with_the_runtime_and_stop_in_quiesce - - name: Run host metrics tests (macOS) - if: matrix.platform == 'mac' - shell: bash - run: | - counted() { - local expected="$1"; shift - local log; log="$(mktemp)" - "$@" 2>&1 | tee "$log" - local passed - passed="$(sed -n 's/^test result: ok\. \([0-9]*\) passed.*/\1/p' "$log" | awk '{ total += $1 } END { print total + 0 }')" - if [ "$expected" = exactly-one ] && [ "$passed" -ne 1 ]; then - echo "::error::expected exactly 1 passed test, got $passed"; exit 1 - fi - if [ "$passed" -lt 1 ]; then - echo "::error::the filter matched no test"; exit 1 - fi - } - counted at-least-one cargo test -p bibcode-server --lib -j 2 -- host_metrics:: - counted exactly-one cargo test -p bibcode-server --test host_metrics_exit -j 2 -- --exact a_held_disk_read_does_not_block_exit - counted exactly-one cargo test -p bibcode-server --lib -j 2 -- --exact production::runtime::tests::host_metrics_start_with_the_runtime_and_stop_in_quiesce - # build-desktop-artifact.ts --symbols runs dump_syms on the unstripped binary. Rust Cache # keeps ~/.cargo/bin, so a restored runner skips the compile. - name: Install dump_syms @@ -493,7 +461,7 @@ jobs: --symbols --verbose - # The macOS legs take 1-4 hours on a 5-job macOS concurrency cap, so pull + # The macOS legs take 1-5 hours on a 5-job macOS concurrency cap, so pull # requests skip them unless labelled ci:full. Main pushes and manual runs # always build them. Same steps as native_desktop. native_desktop_macos: @@ -510,7 +478,10 @@ jobs: platform: mac target: dmg arch: arm64 - jobTimeoutMinutes: 240 + # With a Rust cache this row took 215 minutes on 2026-11-13, 84 of + # them in the exception probe's own full build, which is gone. The + # bundle build alone has taken 75 to 111 minutes. + jobTimeoutMinutes: 330 # The release producer's runner (release.yml), so the Intel build # this tests and the cache it saves are the ones Release uses. # Seeded upgrade keeps macos-26-intel for newest-OS runtime coverage. @@ -519,9 +490,73 @@ jobs: platform: mac target: dmg arch: x64 - jobTimeoutMinutes: 240 + # With a Rust cache this row took 228 minutes on 2026-11-13, 81 of + # them in the exception probe's own full build, which is gone. No + # run without a Rust cache has completed since. + jobTimeoutMinutes: 330 steps: *native_desktop_steps + # Linux runs these server tests in Test and Windows in its native desktop rows; macOS Git + # (LFS, hooks), the index lock owner check (#341) and the host collector (#292) differ. + # They are their own job because the server's test build took 32 to 56 minutes inside the + # macOS native desktop rows and pushed both past their budget. Each host metrics command + # fails the step when its filter matches no test. + # The cache is the native desktop one, restored and never saved: a second macOS entry of + # this size does not fit the 10 GB Actions budget. + server_tests_macos: + name: Server tests - ${{ matrix.label }} + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:full') + runs-on: ${{ matrix.runner }} + timeout-minutes: 150 + strategy: + fail-fast: false + matrix: + include: + - label: macOS arm64 + runner: macos-26 + - label: macOS x64 + runner: macos-15-intel + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@18f0487477426dce244608110aa2660c2893deb7 # 1.98.1 + + - name: Restore Rust build output + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: native-desktop + save-if: "false" + workspaces: | + . -> target + + - name: Run Git system configuration tests (macOS) + run: cargo test -p bibcode-server --test git_system_config -j 2 + + - name: Run background Git index lock tests (macOS) + run: cargo test -p bibcode-server --lib -j 2 -- background_status_ git_stop_ index_lock_cleanup_ + + - name: Run host metrics tests (macOS) + shell: bash + run: | + counted() { + local expected="$1"; shift + local log; log="$(mktemp)" + "$@" 2>&1 | tee "$log" + local passed + passed="$(sed -n 's/^test result: ok\. \([0-9]*\) passed.*/\1/p' "$log" | awk '{ total += $1 } END { print total + 0 }')" + if [ "$expected" = exactly-one ] && [ "$passed" -ne 1 ]; then + echo "::error::expected exactly 1 passed test, got $passed"; exit 1 + fi + if [ "$passed" -lt 1 ]; then + echo "::error::the filter matched no test"; exit 1 + fi + } + counted at-least-one cargo test -p bibcode-server --lib -j 2 -- host_metrics:: + counted exactly-one cargo test -p bibcode-server --test host_metrics_exit -j 2 -- --exact a_held_disk_read_does_not_block_exit + counted exactly-one cargo test -p bibcode-server --lib -j 2 -- --exact production::runtime::tests::host_metrics_start_with_the_runtime_and_stop_in_quiesce + # A real native crash under the crash monitor on every OS, symbolicated with symbols dumped # the way the release pipeline dumps them. # macos-26 runs on main, manual runs, and ci:full pull requests only. diff --git a/Cargo.lock b/Cargo.lock index c8bdeabcb6..e002bc4cce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -501,6 +501,14 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "bibcode-objc-exception-probe" +version = "0.1.0" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "bibcode-server" version = "0.15.3" diff --git a/Cargo.toml b/Cargo.toml index 530d4329e7..d50d06dbb9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,10 @@ [workspace] -members = ["apps/desktop/src-tauri", "apps/server", "tools/updater-verifier"] +members = [ + "apps/desktop/src-tauri", + "apps/server", + "tools/objc-exception-probe", + "tools/updater-verifier", +] resolver = "3" [workspace.package] diff --git a/docs/operations/ci.md b/docs/operations/ci.md index 3f1848613a..a6cbcc4c84 100644 --- a/docs/operations/ci.md +++ b/docs/operations/ci.md @@ -20,7 +20,7 @@ workflow as idle. ## Full CI and the `ci:full` label -The macOS legs take one to four hours each and share a five-job macOS +The macOS legs take one to five hours each and share a five-job macOS concurrency cap with every other workflow, so a pull request runs them only when it carries the `ci:full` label: @@ -29,6 +29,7 @@ when it carries the `ci:full` label: | Check, Test, Release Smoke | yes | yes | | Native desktop: Linux and Windows rows | yes | yes | | Native desktop: macOS arm64 and x64 | no | yes | +| Server tests: macOS arm64 and x64 | no | yes | | Crash smoke: `ubuntu-24.04`, `windows-2025` | yes | yes | | Crash smoke: `macos-26` | no | yes | | Seeded Packaged Desktop Upgrade (all jobs) | no | yes | @@ -137,19 +138,21 @@ the merge. release symbol build (`STRIP=none`, line tables, a packed dSYM on macOS, a GNU build-id on Linux) that Release uses, so it also proves the `dump_syms` path on every pull request; the job installs `dump_syms` 2.3.9 for it, and - Rust Cache keeps the binary in `~/.cargo/bin`. The macOS exception probe uses - the same symbol profile so the dependencies both builds share compile once. + Rust Cache keeps the binary in `~/.cargo/bin`. Each matrix row declares its complete-job budget: - 240 minutes for both macOS architectures, and 120 minutes for the other four targets. + 330 minutes for each macOS row and 120 minutes for the other four targets. That budget includes setup, host tests, the macOS optimized exception-recovery probe, and native packaging without shortening any check or test-owned deadline. - Debug host tests cannot warm the optimized artifacts; the optimized example - also enables dev-dependency features (`tauri/test` and `tokio/test-util`) that - the ordinary production bundle does not use. Both optimized commands select - the same native target triple, but their feature differences require separate - compilation. Keep the real exception probe: a larger complete-job allowance - accommodates these deliberate checks without treating a cached build as the - cold-build baseline. The shared `scripts/run-msvc.mjs` launcher selects + Debug host tests cannot warm the optimized artifacts. The exception probe is + the `bibcode-objc-exception-probe` crate: it depends only on `objc2` and + `objc2-foundation`, with the `objc2` features the desktop graph enables, and + runs under the shipped release profile. Do not move it back into + `bibcode-desktop` as an example: an example enables the dev-dependency + features (`bibcode-server/hermetic-test-guard`, `tauri/test`, + `tokio/test-util`), so it compiled the whole desktop and server graph a + second time, 84 of the 215 minutes of the macOS arm64 row on 2026-11-13. + Check a change to the probe by running it once with + `CARGO_PROFILE_RELEASE_PANIC=abort`, where it must fail. The shared `scripts/run-msvc.mjs` launcher selects the requested MSVC architecture. After the Rust host tests, the Windows row alone runs `vp test run apps/desktop/e2e/support/test-project.test.ts apps/web/src/components/gitManager/gitManagerRepositoryAvailability.test.ts`. That step is the @@ -172,6 +175,15 @@ the merge. spaces, dollar signs, backticks, and typographic quotes. Every Git/home configuration input is fixture-owned. Other platforms skip that native case; their string assertions remain compatibility evidence. +- **Server tests (macOS)** is the job `server_tests_macos`, on `macos-26` and + `macos-15-intel` with the `ci:full` gate above. It runs the server tests whose + behaviour differs on macOS: `git_system_config`, the background Git index lock + tests, and the host metrics tests. Linux runs them in Test and Windows in its + Native desktop rows. They are a separate job because the server's test build + took 32 to 56 minutes inside the macOS Native desktop rows and pushed both past + their budget. The job restores the Native desktop Rust cache and never saves + one. Its budget is 150 minutes. A full run now starts seven macOS jobs against + the five-job cap, so two of them wait for a free runner. - **Crash smoke** runs on `ubuntu-24.04` and `windows-2025` on every pull request, and also on `macos-26` with `ci:full`, on `main`, and in manual runs. It builds with the release symbol profile from @@ -246,7 +258,7 @@ recently used entries above that. Which workflow saves which entry: A job cancelled by `timeout-minutes` or by a newer run skips the cache post step, so the Check and Test budgets above include a cold compile. Native -desktop keeps its 120-minute and 240-minute row budgets. +desktop keeps its 120-minute and 330-minute row budgets. Six native-desktop entries of about 1.6 GB each are about 10 GB on their own, before Check, Test, Crash smoke, Linux Git, and the setup-vp caches. Eviction diff --git a/docs/operations/release.md b/docs/operations/release.md index afe0184e2d..87aba4839d 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -71,7 +71,7 @@ Objective-C exception recovery when navigation cancels an in-flight request; `panic=abort` turns that recoverable condition into process termination. Thin LTO, one codegen unit, and symbol stripping remain enabled; the release jobs strip their binaries after dumping symbols (see [Symbol assets](#symbol-assets)). -Native macOS CI executes the release-profile `objc_exception_probe`, and the +Native macOS CI runs the `bibcode-objc-exception-probe` crate in the release profile, and the desktop crate rejects an abort profile on macOS. Each matrix job installs the frontend build toolchain, Rust, and the pinned diff --git a/docs/reference/workspace-layout.md b/docs/reference/workspace-layout.md index 0afa3b8181..341a10ec8e 100644 --- a/docs/reference/workspace-layout.md +++ b/docs/reference/workspace-layout.md @@ -23,6 +23,8 @@ explicit package subpaths. - `/infra/relay`: BiBCode Connect relay deployment package. - `/oxlint-plugin-bibcode`: repository-specific Oxlint rules. +- `/tools/objc-exception-probe`: Rust probe that checks Objective-C exception + recovery under the release profile on macOS. - `/tools/updater-verifier`: Rust tool used to verify signed updater artifacts. - `/third_party/portable-pty`: vendored Rust PTY crate included in the Cargo workspace. diff --git a/docs/testing/macos-desktop.md b/docs/testing/macos-desktop.md index 00ace5d0ae..b874f276bb 100644 --- a/docs/testing/macos-desktop.md +++ b/docs/testing/macos-desktop.md @@ -118,7 +118,7 @@ Validate Objective-C recovery with the actual optimized profile: ```sh rust_target="$(rustc -Vv | sed -n 's/^host: //p')" -cargo run --release --target "$rust_target" -p bibcode-desktop --example objc_exception_probe +cargo run --release --target "$rust_target" -p bibcode-objc-exception-probe ``` The probe must catch its native exception and exit successfully. The workspace diff --git a/scripts/ci-platform-contract.test.ts b/scripts/ci-platform-contract.test.ts index c5ccd68b56..0a1269a669 100644 --- a/scripts/ci-platform-contract.test.ts +++ b/scripts/ci-platform-contract.test.ts @@ -329,13 +329,64 @@ describe("cross-platform CI contract", () => { const matrix = nativeDesktopRows(workflow); expect(matrix).toHaveLength(6); for (const row of matrix) { - // macOS's cold optimized probe and native package use distinct dependency - // feature sets, in addition to the unoptimized host test graph. - const minimum = row.platform === "mac" ? 240 : 120; + // macOS builds the unoptimized host test graph and the optimized native + // package with its symbols. + // With a Rust cache the macOS rows took 215 and 228 minutes on 2026-11-13. + const minimum = row.platform === "mac" ? 330 : 120; expect(row.jobTimeoutMinutes, String(row.label)).toBeGreaterThanOrEqual(minimum); } }); + it("probes macOS exception recovery without a second desktop build", () => { + const { workflow } = readWorkflow(CI_WORKFLOW_PATH); + const probe = requireJob(workflow, "native_desktop").steps?.find( + (step) => step.name === "Verify macOS release exception recovery", + ); + expect(probe?.if).toBe("matrix.platform == 'mac'"); + expect(probe?.run).toContain("cargo run --release"); + expect(probe?.run).toContain("-p bibcode-objc-exception-probe"); + // An example of bibcode-desktop would compile the desktop and server + // graph a second time. No profile overrides: the probe tests the shipped + // release profile. + expect(probe?.run).not.toContain("bibcode-desktop"); + expect(probe?.env).toBeUndefined(); + const manifest = NodeFS.readFileSync( + NodePath.join(REPOSITORY_ROOT, "tools/objc-exception-probe/Cargo.toml"), + "utf8", + ); + expect(manifest).not.toMatch(/bibcode-(desktop|server)|tauri/); + expect( + NodeFS.existsSync( + NodePath.join(REPOSITORY_ROOT, "apps/desktop/src-tauri/examples/objc_exception_probe.rs"), + ), + ).toBe(false); + }); + + it("runs the macOS server tests off the native desktop job, without saving a cache", () => { + const { workflow } = readWorkflow(CI_WORKFLOW_PATH); + const job = requireJob(workflow, "server_tests_macos"); + expect(job.if).toBe(FULL_CI); + const steps = job.steps ?? []; + const runs = steps.map((step) => String(step.run ?? "")).join("\n"); + for (const command of [ + "cargo test -p bibcode-server --test git_system_config", + "-- background_status_ git_stop_ index_lock_cleanup_", + "-- host_metrics::", + "--test host_metrics_exit", + "host_metrics_start_with_the_runtime_and_stop_in_quiesce", + ]) { + expect(runs, command).toContain(command); + } + const cache = steps.find((step) => String(step.uses ?? "").startsWith("Swatinem/rust-cache@")); + expect(cache?.with?.["save-if"]).toBe("false"); + // The server's test build is what pushed the macOS rows past their budget. + const native = (requireJob(workflow, "native_desktop_macos").steps ?? []) + .filter((step) => !/matrix\.platform == '(win|linux)'/.test(String(step.if ?? ""))) + .map((step) => String(step.run ?? "")) + .join("\n"); + expect(native).not.toContain("cargo test -p bibcode-server"); + }); + it("builds native desktop bundles on every supported runner and architecture", () => { const { workflow } = readWorkflow(CI_WORKFLOW_PATH); const matrix = nativeDesktopRows(workflow); diff --git a/scripts/hermetic-guard-contract.test.ts b/scripts/hermetic-guard-contract.test.ts index 0bfc11c586..7b5ecbc7c4 100644 --- a/scripts/hermetic-guard-contract.test.ts +++ b/scripts/hermetic-guard-contract.test.ts @@ -11,6 +11,7 @@ const manifests = [ "Cargo.toml", "apps/server/Cargo.toml", "apps/desktop/src-tauri/Cargo.toml", + "tools/objc-exception-probe/Cargo.toml", "tools/updater-verifier/Cargo.toml", ]; diff --git a/scripts/rust-workspace.test.ts b/scripts/rust-workspace.test.ts index 06f7de18d2..08c6171ce3 100644 --- a/scripts/rust-workspace.test.ts +++ b/scripts/rust-workspace.test.ts @@ -27,6 +27,10 @@ const rustPackages = [ packageJsonPath: "apps/server/package.json", cargoSelectedScriptNames: ["build", "start", "test", "typecheck"], }, + { + cargoPackage: "bibcode-objc-exception-probe", + memberPath: "tools/objc-exception-probe", + }, { cargoPackage: "bibcode-updater-verifier", memberPath: "tools/updater-verifier", @@ -95,7 +99,7 @@ it.layer(NodeServices.layer)("canonical Rust workspace", (it) => { ); assert.equal( ciWorkflow.match(/uses: dtolnay\/rust-toolchain@[0-9a-f]{40} # 1\.98\.1/g)?.length ?? 0, - 4, + 5, "Every Rust CI job must exercise the declared Rust 1.98.1 toolchain", ); diff --git a/tools/objc-exception-probe/Cargo.toml b/tools/objc-exception-probe/Cargo.toml new file mode 100644 index 0000000000..76c312cfa0 --- /dev/null +++ b/tools/objc-exception-probe/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "bibcode-objc-exception-probe" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +publish = false + +# The objc2 features the macOS desktop app's dependency graph enables (`cargo +# tree -e features -i objc2` on the desktop package), so this probe unwinds +# through the same objc2 build as the app, under the same release profile. +# No dependency on the app's own crates: that is what keeps this build small. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = { workspace = true, features = ["exception", "disable-encoding-assertions"] } +objc2-foundation.workspace = true + +[lints] +workspace = true diff --git a/apps/desktop/src-tauri/examples/objc_exception_probe.rs b/tools/objc-exception-probe/src/main.rs similarity index 100% rename from apps/desktop/src-tauri/examples/objc_exception_probe.rs rename to tools/objc-exception-probe/src/main.rs