kunobi-daemon 0.10.1

Process ownership, request draining, and verified upgrades for local daemons
Documentation
name: CI

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    name: Test (${{ matrix.os }})
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
      - run: rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy
      # Installs the tools pinned in mise.toml (cargo-nextest).
      - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4
      - run: cargo fmt --check
      - run: cargo clippy --locked --all-features --all-targets -- -D warnings
      - run: cargo clippy --locked --no-default-features --all-targets -- -D warnings
      - run: cargo clippy --locked --no-default-features --features wire,local,launch --all-targets -- -D warnings
      # A link to a module or item that exists only on one platform or behind a
      # feature breaks the docs everywhere else, docs.rs (Linux) included.
      - run: cargo doc --locked --all-features --no-deps
        env:
          RUSTDOCFLAGS: -D warnings
      - run: cargo doc --locked --no-default-features --no-deps
        env:
          RUSTDOCFLAGS: -D warnings
      # One process per test (see .config/nextest.toml): process-wide state
      # cannot leak between tests, and a flaky test fails the run by name.
      - run: cargo nextest run --locked --all-features
      - run: cargo nextest run --locked --no-default-features --features wire,local
      - run: cargo nextest run --locked --no-default-features --features wire,local,launch
      - run: cargo nextest run --locked --no-default-features --features wire
      - run: cargo nextest run --locked --no-default-features
      # nextest does not run doctests.
      - run: cargo test --locked --all-features --doc
      - run: cargo run --locked --no-default-features --features wire --example application_messages
      - run: cargo package --locked

  cross-version:
    name: Cross-version health and drain
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          path: current
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          ref: v0.8.0
          path: previous
      - run: rustup toolchain install 1.95.0 --profile minimal
      # The schema check compiles both schemas and compares their descriptors.
      - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler python3-protobuf
      - run: python3 current/scripts/check-cross-version.py previous current
        env:
          RUSTUP_TOOLCHAIN: 1.95.0

  protobuf:
    name: Protobuf schema
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
      - run: rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy
      - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
      - run: cargo fmt --manifest-path tools/proto-gen/Cargo.toml --check
      - run: cargo clippy --locked --manifest-path tools/proto-gen/Cargo.toml -- -D warnings
      - run: cargo run --locked --manifest-path tools/proto-gen/Cargo.toml -- --check
      - run: python3 scripts/check-protobuf.py

  msrv:
    name: Rust 1.89
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
      - run: rustup toolchain install 1.89.0 --profile minimal
      - run: cargo +1.89.0 test --locked --all-features
      - run: cargo +1.89.0 test --locked --no-default-features --features wire
      - run: cargo +1.89.0 test --locked --no-default-features

  # Native windows-latest cannot see mixed-case .lib names. cargo check does
  # not link; this job is the cargo-xwin path Kunobi's sidecar uses.
  xwin:
    name: Windows cross-link (xwin)
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
      - run: rustup toolchain install 1.95.0 --profile minimal
      - run: rustup target add x86_64-pc-windows-msvc
      - run: sudo apt-get update && sudo apt-get install -y clang lld
      - run: cargo install cargo-xwin --locked --version 0.23.1
      - run: cargo xwin build --locked --all-features --all-targets --target x86_64-pc-windows-msvc

  # Model-checks the replacement and selection rules, wire negotiation,
  # admission budgets and retry budgets for every input, not only
  # the cases a test was written for. Same verifier as kache.
  kani:
    name: Kani proofs
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
      - run: |
          cargo install --locked kani-verifier --version 0.67.0
          cargo kani setup
      # A harness that stops compiling under cfg(kani) would otherwise leave
      # this job green with nothing verified.
      - name: Require at least one harness
        run: |
          cargo kani --all-features list --format json
          count="$(python3 -c 'import json; print(json.load(open("kani-list.json"))["totals"]["standard-harnesses"])')"
          echo "harnesses: $count"
          test "$count" -ge 1
      # All features: the wire harnesses sit behind the `wire` feature.
      - run: cargo kani --all-features --output-format terse

  # Explores every interleaving of WriterSlot's Mutex/Condvar state machine.
  # Default features stay off because Tokio reacts to cfg(loom) internally.
  loom:
    name: Loom (WriterSlot, Outstanding)
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
      - run: rustup toolchain install 1.95.0 --profile minimal
      - run: cargo test --release --locked --no-default-features --test loom_writer_slot --test loom_outstanding
        env:
          RUSTFLAGS: --cfg loom

  # RustSec advisories, yanked versions, the licence allow-list and allowed
  # sources, per deny.toml. Same policy as kache.
  deny:
    name: cargo-deny
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
      - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1
        with:
          command: check

  # Changed-line mutation testing: every mutant cargo-mutants makes from a line
  # this PR adds or modifies must fail a test. The plan lists the mutants, the
  # shards test them, and "Mutation testing" is the one check to require.
  mutation-plan:
    name: Mutation plan
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    outputs:
      count: ${{ steps.plan.outputs.count }}
      matrix: ${{ steps.plan.outputs.matrix }}
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          fetch-depth: 0
      - run: rustup toolchain install 1.95.0 --profile minimal
      # Installs the tools pinned in mise.toml (cargo-mutants).
      - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4
      - id: plan
        env:
          BASE_REF: ${{ github.base_ref }}
          PLAN_DIR: ${{ runner.temp }}/mutants
        run: |
          set -euo pipefail
          mkdir -p "$PLAN_DIR"
          # Nothing to test must never be the result of a diff that could not
          # be produced or read, so every step below fails loudly on its own.
          git fetch --no-tags origin "+refs/heads/$BASE_REF:refs/remotes/origin/$BASE_REF"
          base="$(git merge-base "origin/$BASE_REF" HEAD)"

          # Added or modified library source. A deleted file or a pure rename
          # (R100) has no new lines to mutate.
          changed="$(
            git diff --name-status "$base" HEAD -- 'src/*.rs' |
              awk -F '\t' '$1 ~ /^[AMT]/ { print $2 } $1 ~ /^R/ && $1 != "R100" { print $3 }'
          )"
          if [ -z "$changed" ]; then
            echo "::notice title=Mutation testing skipped::This PR adds or changes no Rust source under src/."
            echo 'count=0' >> "$GITHUB_OUTPUT"
            echo 'matrix={"include":[{"index":0,"display":1,"total":1}]}' >> "$GITHUB_OUTPUT"
            exit 0
          fi
          printf 'Changed Rust source:\n%s\n' "$changed"

          # cargo-mutants matches the new side of the diff against this tree
          # by its "+++ b/<path>" header. Pin git's default format whatever the
          # runner's config says, and check every changed file is there: one
          # it cannot match would silently contribute no mutants.
          git -c diff.noprefix=false -c diff.mnemonicPrefix=false \
            diff --no-color --no-ext-diff "$base" HEAD > "$PLAN_DIR/pr.diff"
          while IFS= read -r file; do
            if ! grep -qxF "+++ b/$file" "$PLAN_DIR/pr.diff"; then
              echo "::error title=Mutation plan::$file changed but the diff has no '+++ b/$file' header"
              exit 1
            fi
          done <<< "$changed"

          # --level error keeps log lines off stdout, which then holds only
          # the JSON list. cargo-mutants prints nothing when the changed lines
          # contain no mutants (comments, tests, signatures, excluded code).
          status=0
          cargo mutants --in-diff "$PLAN_DIR/pr.diff" --list --json --level error \
            > "$PLAN_DIR/mutants.json" || status=$?
          if [ "$status" -ne 0 ]; then
            echo "::error title=Mutation plan::cargo mutants could not use the PR diff (exit $status; 5: it does not match the tree, 6: it could not be parsed)"
            exit 1
          fi
          count="$(python3 -c '
          import json, sys
          text = open(sys.argv[1]).read()
          mutants = json.loads(text) if text.strip() else []
          assert isinstance(mutants, list), "expected a JSON list of mutants"
          print(len(mutants))
          ' "$PLAN_DIR/mutants.json")"
          echo "changed-line mutants: $count"
          if [ "$count" -eq 0 ]; then
            echo "::notice title=Mutation testing skipped::The changed lines in src/ produce no mutants."
          fi

          # About 20 mutants per shard, at most 12 shards. Round-robin in the
          # shards spreads slow mutants evenly.
          shards=$(( count == 0 ? 1 : (count + 19) / 20 ))
          shards=$(( shards > 12 ? 12 : shards ))
          matrix="$(python3 -c '
          import json, sys
          total = int(sys.argv[1])
          print(json.dumps({"include": [
              {"index": i, "display": i + 1, "total": total} for i in range(total)
          ]}, separators=(",", ":")))
          ' "$shards")"
          echo "count=$count" >> "$GITHUB_OUTPUT"
          echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
      - if: steps.plan.outputs.count != '0'
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: mutation-diff
          path: ${{ runner.temp }}/mutants/pr.diff
          if-no-files-found: error
          retention-days: 14

  mutation-diff:
    name: Mutation testing (${{ matrix.display }}/${{ matrix.total }})
    needs: mutation-plan
    if: github.event_name == 'pull_request' && needs.mutation-plan.outputs.count != '0'
    runs-on: ubuntu-latest
    # A shard holds at most 20 mutants below 240 and about 85 for a PR that
    # touches the whole crate; each takes 10 to 30 s.
    timeout-minutes: 60
    strategy:
      fail-fast: false
      matrix: ${{ fromJSON(needs.mutation-plan.outputs.matrix) }}
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
      - run: rustup toolchain install 1.95.0 --profile minimal
      # Installs the tools pinned in mise.toml (cargo-mutants, cargo-nextest).
      - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: mutation-diff
          path: ${{ runner.temp }}/mutants
      # Every shard runs the unmutated baseline first: it sets the test
      # timeout and fails the shard if the tests already fail.
      - name: Test mutants of the changed lines
        env:
          OUT: ${{ runner.temp }}/mutants
          SHARD: ${{ matrix.index }}
          SHARDS: ${{ matrix.total }}
          PLANNED: ${{ needs.mutation-plan.outputs.count }}
        run: |
          set -euo pipefail
          test -s "$OUT/pr.diff"
          status=0
          cargo mutants --in-diff "$OUT/pr.diff" \
            --shard "$SHARD/$SHARDS" --sharding round-robin \
            --output "$OUT" || status=$?
          # Round-robin gives shard k every mutant i with i % n == k.
          expected=$(( PLANNED / SHARDS + (SHARD < PLANNED % SHARDS ? 1 : 0) ))
          python3 scripts/mutation-verdict.py "$OUT" "$status" "$expected"
      - if: failure()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: mutants-out-${{ matrix.display }}
          path: ${{ runner.temp }}/mutants/mutants.out
          if-no-files-found: warn
          retention-days: 14

  mutation:
    name: Mutation testing
    needs: [mutation-plan, mutation-diff]
    if: always() && github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - env:
          PLAN: ${{ needs.mutation-plan.result }}
          COUNT: ${{ needs.mutation-plan.outputs.count }}
          SHARDS: ${{ needs.mutation-diff.result }}
        run: |
          echo "plan: $PLAN; mutants: ${COUNT:-none}; shards: $SHARDS"
          test "$PLAN" = success
          if [ "$COUNT" = 0 ]; then exit 0; fi
          test "$SHARDS" = success

  # Instruction counts under Valgrind do not depend on the runner's load, so
  # they can gate a shared CI machine. One job measures the parent commit and
  # then this one: for a pull request that is the base branch tip and the merge
  # result, for a push to main the previous and the new main. Only pull
  # requests fail on a regression. See docs/benchmarks.md.
  bench:
    name: Benchmarks
    runs-on: ubuntu-latest
    timeout-minutes: 30
    defaults:
      run:
        # Adds pipefail, so a failed `cargo metadata` stops the step.
        shell: bash
    env:
      # Shared by both checkouts: dependencies build once, and the second run
      # finds the baseline the first one saved.
      CARGO_TARGET_DIR: ${{ github.workspace }}/benches/target
      GUNGRAUN_HOME: ${{ github.workspace }}/benches/target/gungraun
      # Largest instruction-count growth, in percent, a pull request may add
      # to any benchmark.
      BENCH_LIMIT: "5"
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          # The commit and its first parent.
          fetch-depth: 2
      - run: rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy
      - run: sudo apt-get update && sudo apt-get install -y valgrind
      - run: cargo fmt --manifest-path benches/Cargo.toml --check
      - run: cargo clippy --locked --manifest-path benches/Cargo.toml --all-targets -- -D warnings
      # The runner refuses to run a library of another version, so install
      # exactly the version the lockfile resolves.
      - name: Install gungraun-runner
        id: runner
        run: |
          version="$(cargo metadata --locked --format-version 1 --manifest-path benches/Cargo.toml |
            jq -r '.packages[] | select(.name == "gungraun") | .version')"
          echo "version=$version" >> "$GITHUB_OUTPUT"
          cargo install --locked gungraun-runner --version "$version"
      - name: Measure the parent commit
        id: base
        env:
          RUNNER_VERSION: ${{ steps.runner.outputs.version }}
        run: |
          base="$(git rev-parse HEAD^1)"
          echo "sha=$base" >> "$GITHUB_OUTPUT"
          git worktree add --detach "$RUNNER_TEMP/base" "$base"
          cd "$RUNNER_TEMP/base"
          if [ ! -f benches/Cargo.toml ]; then
            echo "The parent commit has no benchmarks; every benchmark is new."
            exit 0
          fi
          version="$(cargo metadata --locked --format-version 1 --manifest-path benches/Cargo.toml |
            jq -r '.packages[] | select(.name == "gungraun") | .version')"
          if [ "$version" != "$RUNNER_VERSION" ]; then
            echo "::notice::The parent commit uses gungraun $version; no comparison this time."
            exit 0
          fi
          cargo bench --locked --manifest-path benches/Cargo.toml -- --save-baseline=base
      - name: Measure this commit
        run: |
          limits=()
          if [ "$GITHUB_EVENT_NAME" = pull_request ]; then
            limits=("--callgrind-limits=ir=${BENCH_LIMIT}%")
          fi
          cargo bench --locked --manifest-path benches/Cargo.toml -- \
            --baseline=base --save-summary=json "${limits[@]}"
      # Also after a failed measurement, so the table shows what regressed.
      - name: Report
        if: success() || failure()
        env:
          BASE_SHA: ${{ steps.base.outputs.sha }}
        run: |
          limit=()
          if [ "$GITHUB_EVENT_NAME" = pull_request ]; then
            limit=(--limit "$BENCH_LIMIT")
          fi
          python3 scripts/bench-report.py "$GUNGRAUN_HOME" "${limit[@]}" \
            --baseline "the parent commit, $BASE_SHA" >> "$GITHUB_STEP_SUMMARY"