name: CI on: push: branches: [main] pull_request: env: CARGO_TERM_COLOR: always RUSTFLAGS: "-D warnings" # Cancel a pull request's superseded runs instead of letting them burn # to completion. This project rebases heavily — the ledger re-conflicts # on nearly every merge — so a branch routinely takes several pushes # while an earlier run is still going, and each of those runs is # obsolete the moment the next push lands. macOS minutes are the # expensive ones and the macOS leg is the critical path, so superseded # runs are exactly where the waste concentrates. # # Scoped to pull requests deliberately. `github.event.pull_request.number` # is empty for a push to `main`, so the fallback keys those runs by SHA: # every `main` commit gets its own group and none can cancel another. # Cancelling a `main` run would leave the branch-protection record # ambiguous about a commit that has already landed — the one place this # saving is not worth having. concurrency: group: ci-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} # Every job carries `timeout-minutes`. Without one a job inherits # GitHub's 360-minute default, so a single hung test burns six hours — # times four on the test matrix — and reports nothing useful at the end # of it. # # The ceilings are justified against OBSERVED EXECUTION, and the # numbers are a reading rather than a constant, so re-measure before # trusting them: # # * observed max, 25-run window: 17 min (macOS/luajit) # * observed max, 12-run window: 15.8 min (same job) # * every other job: under 4 min # # `timeout-minutes` counts EXECUTION, not queue time — a 33-minute # wall-clock run in that window executed its longest job in 17 — so no # run in the observed history would have been killed by these values. # # The exposure is the case the window does NOT contain: a COLD CACHE. # A stable-toolchain bump invalidates Swatinem's key on every leg at # once, and a cold macOS debug build of this workspace plus the suite is # the plausible way a HEALTHY run exceeds its ceiling. The test job # therefore gets 35 rather than 25 — roughly 2x its observed max — while # everything else keeps 25 against a sub-4-minute observed max, except # `m6-perf-gates`, which keeps its own tighter 15. # # DIAGNOSIS, WRITTEN BEFORE IT HAPPENS: four test legs timing out # simultaneously, shortly after a Rust release, is a cold cache and not # a hang. Rerun, or raise this number. A single leg timing out while its # siblings pass is the hang case these ceilings exist to catch. # # This is also the gate that has to exist before the basedpyright-class # hang can ever be armed — see `PMACS_REQUIRE_PYRIGHT`, deliberately # never set, in the test job below. 35 still beats the 360-minute # default by an order of magnitude. jobs: fmt: name: Format runs-on: ubuntu-latest timeout-minutes: 25 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt - run: cargo fmt --all --check clippy: name: Lint (${{ matrix.lua }}) runs-on: ubuntu-latest timeout-minutes: 25 strategy: fail-fast: false matrix: # mlua's Lua-flavor features are mutually exclusive, so we can't # use --all-features. Run clippy under each supported flavor. lua: [luajit, lua54] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: components: clippy - uses: Swatinem/rust-cache@v2 - run: cargo clippy --all-targets --no-default-features --features ${{ matrix.lua }} -- -D warnings # pmacs-gpu is Lua-flavor-independent (it depends on pmacs-protocol, # not pmacs), so the root-package clippy above never lints it. Lint # it explicitly or its warnings slip through CI (audit F-001). - run: cargo clippy -p pmacs-gpu --all-targets -- -D warnings # pmacs-protocol is likewise never linted by the root-package # clippy above: the workspace default member is only `pmacs`. The # local `--workspace` gate covers it, so it passes today — CI has # simply never checked, and a warning introduced through a # protocol-only PR would reach `main` unseen. - run: cargo clippy -p pmacs-protocol --all-targets -- -D warnings gpu-render: name: GPU Render (headless) runs-on: ubuntu-latest timeout-minutes: 25 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 # The headless render harness (audit F-014) needs a Vulkan adapter; # the runner has no GPU, so install the mesa software rasterizer # (lavapipe). This job also runs pmacs-gpu's other tests, which the # workspace test job (root package only) never executes. - name: Install lavapipe (Vulkan software rasterizer) run: | sudo apt-get update sudo apt-get install -y mesa-vulkan-drivers vulkan-tools - name: Confirm a Vulkan adapter is present run: | ls -la /usr/share/vulkan/icd.d/ || true vulkaninfo --summary || true - name: pmacs-gpu tests (render harness included) env: # Force the Vulkan backend so wgpu uses lavapipe. Do NOT set # VK_ICD_FILENAMES — the loader's default ICD discovery already # finds lavapipe (vulkaninfo above proves it), and pinning a # path that doesn't match the runner hides every ICD. The # PMACS_REQUIRE_GPU guard turns a missing adapter into a hard # failure so a broken setup can't pass as a silent skip. WGPU_BACKEND: vulkan LIBGL_ALWAYS_SOFTWARE: "1" PMACS_REQUIRE_GPU: "1" run: cargo test -p pmacs-gpu -- --test-threads=1 test: name: Test (${{ matrix.os }} / ${{ matrix.lua }}) runs-on: ${{ matrix.os }} # 35, not 25: this is the only job whose observed max is minutes # rather than seconds, and the only one a cold cache can plausibly # push past a 25-minute ceiling on all four legs at once. See the # note above `jobs:` for the measurements and the cold-cache # diagnosis. timeout-minutes: 35 strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] lua: [luajit, lua54] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 # Arm the external-tool-gated tests (see `TEST_IMPROVEMENT.md` # §1.2). Before this step nothing installed these tools, so every # test guarded on them returned early and reported GREEN without # executing its body — a whole block of real-language-server and # multi-shell coverage that had never once run in CI. Installing # them is only half the fix; the `PMACS_REQUIRE_*` variables below # are what turn a future missing tool back into a failure instead # of silently restoring the vacuum. # # Linux only for now, deliberately. macOS would need the brew # equivalents and roughly doubles the install cost on the slowest # leg of the matrix; arming one platform already converts these # from never-executed to executed, and the second is incremental. # The tests still skip cleanly on macOS because the variables are # unset there. - name: Install external tools that gate acceptance tests (Linux) if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y clangd zsh fish lua5.4 # `locate_lua` looks for `lua` or `luajit` by name; the # distro package installs `lua5.4` only. sudo ln -sf "$(command -v lua5.4)" /usr/local/bin/lua # rust-analyzer belongs HERE, not on the shared toolchain # step. `components:` there applies to every matrix leg, so it # would install the binary on macOS too — and *presence*, not # PMACS_REQUIRE_LSP, is what decides whether a gated test body # runs. That would have executed the rust-analyzer tests on # macOS for the first time ever, on the legs that are both the # CI critical path and the documented flake surface, while # this lane's text claimed Linux only. rustup component add rust-analyzer # Versions are PINNED. `@latest` and bare `npm install -g` # make CI behaviour drift with upstream releases: a bad gopls # or yaml-language-server publish then breaks CI with no # commit in this repository to bisect against. go install golang.org/x/tools/gopls@v0.16.2 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" npm install -g vscode-langservers-extracted@4.10.0 \ yaml-language-server@1.15.0 - run: cargo build --all-targets --no-default-features --features ${{ matrix.lua }} # Several acceptance binaries spawn real daemon / PTY child # processes. Keep the harness serial so macOS runners do not # expose cross-test process lifecycle races that are unrelated # to the behavior under test. # # PMACS_REQUIRE_* make a missing tool fatal rather than a silent # skip, exactly as PMACS_REQUIRE_GPU already does for the headless # render job. Set only where the install step ran. # # PMACS_REQUIRE_PYRIGHT is deliberately NOT set and basedpyright # is deliberately NOT installed. Both original reasons are now # gone: the hang's root cause was the stdin-field drop ordering in # `RuntimeHandles::drop` and is fixed, and this job now carries # `timeout-minutes`, so a hang could no longer burn six hours. # The ONE remaining reason is the plain one --- basedpyright is not # installed here, so arming the variable would fail rather than # test anything. Installing it (a uv + bundled-node download on # every leg) is its own decision, not a rider on the hang fix. # # PMACS_REQUIRE_SETSID arms the teardown-deadlock unit test. Its # fixture orphans a grandchild with `setsid --fork`, which is # util-linux rather than coreutils, so the test skips when the # binary is absent (a minimal container must not fail `--lib` # without ever testing pmacs) and this variable is what makes the # skip fatal where the tool is guaranteed. # # PMACS_REQUIRE_BASH arms the signal diagnostic's job-control # corroboration test, which needs `/bin/bash` and `-m` putting a # foreground job in its own process group with the terminal. # # Linux only, and NOT because macOS lacks bash — it ships 3.2. CI # measured the difference: on both macOS legs a non-interactive # `bash -m` kept the terminal on the leader for a full 10s wait, so # the divergence the test needs does not occur there. The test skips # on non-Linux by platform check; arming it on macOS would only make # a missing binary fatal for a test that cannot run anyway. # # The divergent case itself is pinned on every platform by # injecting the foreground group instead (framing Bet 1's stated # fallback, after the real fixture failed its own falsifier). - run: cargo test --all-targets --no-default-features --features ${{ matrix.lua }} -- --test-threads=1 env: PMACS_REQUIRE_LSP: ${{ runner.os == 'Linux' && '1' || '' }} PMACS_REQUIRE_SHELLS: ${{ runner.os == 'Linux' && '1' || '' }} PMACS_REQUIRE_LUA: ${{ runner.os == 'Linux' && '1' || '' }} PMACS_REQUIRE_SETSID: ${{ runner.os == 'Linux' && '1' || '' }} PMACS_REQUIRE_BASH: ${{ runner.os == 'Linux' && '1' || '' }} - run: cargo test --doc --no-default-features --features ${{ matrix.lua }} # The workspace default member is only the root `pmacs` package, so # the runs above never execute pmacs-protocol's own tests — the # shared wire format (encode/decode, transport framing) the daemon, # TUI, and GPU all depend on. Run them explicitly (Lua-flavor # independent, so once is enough; runs on every matrix leg cheaply # off the shared cache). - run: cargo test -p pmacs-protocol --all-targets acceptance: name: M1 Acceptance Gates runs-on: ubuntu-latest timeout-minutes: 25 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: perf, memory, and 30s fuzz gates run: cargo test --release --test acceptance -- --ignored --nocapture --test-threads=1 m4-perf-gates: name: M4 Perf Gates runs-on: ubuntu-latest timeout-minutes: 25 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: tree-sitter parse + highlight latency budgets run: cargo test --release --test m4_acceptance -- --ignored --nocapture --test-threads=1 # m5-perf-gates: luajit-only by design. # The keystroke-to-render gate measures protocol-path latency (Unix # socket frontend ↔ daemon RTT plus the daemon's keymap-dispatch / # buffer-mutate / repaint pipeline). Lua flavor is irrelevant — the # measured path doesn't enter the Lua VM. Matrixing over flavors # doubles CI cost without adding signal. If a future perf gate # measures Lua-touching code paths (command dispatch via Lua, # hook invocation), revisit then. m5-perf-gates: name: M5 Perf Gates runs-on: ubuntu-latest timeout-minutes: 25 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: keystroke-to-render p99 over loopback LocalSocket run: cargo test --release --test m5_perf_acceptance -- --ignored --nocapture # m6-perf-gates: luajit-only by design. The M6.6/M6.7 gates measure # rope-append throughput, RSS ceiling, cancel-response latency, # scrollback navigation latency, and scrollback search latency — # none of which exercise the Lua VM's hot path differently across # flavors. Same reasoning as m5-perf-gates above. # # --test-threads=1 is required: the M6.6 RSS-ceiling gate samples # process-wide /proc/self/status, and concurrent ingest / scrollback # populate from sibling tests inflate that reading. Serializing # gives each test a clean baseline. The wall-clock cost of serial # execution (~5 minutes) is acceptable for a perf-gates job that # only runs on push/PR. m6-perf-gates: name: M6 Perf Gates runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: ingest rate, RSS ceiling, cancel p99, navigation p99, search p99 env: # Full cancel profile remains the test default. Hosted CI has # an effective five-minute ceiling for this job and variable # PTY throughput, so keep the per-PR profile short and stable. PMACS_M6_INGEST_MIN_BYTES_PER_SEC: "67108864" PMACS_M6_CANCEL_TRIALS: "30" PMACS_M6_CANCEL_MAX_DELAY_MS: "500" run: cargo test --release --test m6_perf_acceptance -- --ignored --nocapture --test-threads=1 # m10-perf-gates: the M10 suites, which were dark for TWO independent # reasons. Both are fixed here. # # 1. They are `crdt`-gated, and nothing in this workflow enabled the # feature, so they were never compiled. That is this lane's subject. # 2. Even setting `crdt` aside, NO job named them. Grepping this file # for `--test` before this job existed yielded exactly four suites: # acceptance, m4_acceptance, m5_perf_acceptance, m6_perf_acceptance. # Their `#[ignore]` is deliberate; their absence from CI was not. # # luajit-only, for the same reason m5-perf-gates and m6-perf-gates are: # the measured paths (CRDT buffer mutation, socket round-trips) do not # enter the Lua VM, so matrixing over flavors doubles cost for no # signal. # # WHAT EACH SUITE ACTUALLY GATES — these differ, and the difference # matters for how a red run is read: # # * m10_11_perf asserts ONE budget: cross-frontend propagation p99 # under 50ms. Observed 1.47ms locally (2026-08-01), a ~34x margin, # so a red here is a real regression rather than runner noise. # # * m10_2_perf asserts NOTHING. It is six measurement benches that # print throughput numbers — the baselines M10.2's 391x unicode # finding and v0.2+ optimization work compare against. It cannot # fail a budget because it has none. # # It is here anyway, and NOT as a perf gate: `run_workload` drives # 30 seconds of randomized mixed edits against both the v0.1 and # CRDT buffer paths, and nothing else in the corpus exercises a # sustained randomized CRDT workload. Its CI value is soak and # panic detection. Do not "fix" a future silent run by adding # budget assertions — the numbers are deliberately reported, not # enforced, and asserting throughput on shared runners is how perf # jobs become flaky. # # OBSERVED EXECUTION (local, 2026-08-01, release): m10_2_perf 79s # (6 tests), m10_11_perf 5s (1 test) — about 85s combined. The ceiling # is 25 rather than something near that, because a perf job's cost is # dominated by its COLD-CACHE RELEASE BUILD, not its tests; this is # the same reasoning that gives m5-perf-gates 25. m10-perf-gates: name: M10 Perf Gates (crdt) runs-on: ubuntu-latest timeout-minutes: 25 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: CRDT buffer throughput baselines (soak; asserts nothing) run: cargo test --release --features crdt --test m10_2_perf -- --ignored --nocapture - name: cross-frontend propagation p99 over a real socket run: cargo test --release --features crdt --test m10_11_perf -- --ignored --nocapture