Skip to content

Make the test suite faster #174

Closed
6 of 6 issues completed
Closed

Description

@JacobStephens2

This was generated by AI from a grilling session with the maintainer.

Problem Statement

The full test suite is too slow for the agent sessions inside a Run. With three Runs on a 4-core machine, a full cargo test took about 15–16 minutes (load 14–26). That is longer than the 600s Bash timeout the sessions run under, so the headless instruction to "run tests in the foreground" cannot be followed. Sessions then improvise waits that go wrong (#180), and each Run pays that cost again after every Repair. Every new Run worktree also starts with a cold build of about 10 minutes before the first test runs. CI, which every Run waits on at least once, pays the same costs.

Measured on 2026-09-30 (4 cores, load 14–26):

  • Cold cargo test --no-run: 616s wall, 329s user + 53s sys. Top units: aws-lc-sys build script 372s (via reqwest → rustls), moxcms 113s, tokio 75s, image 56s, pxfm 54s (image/moxcms/pxfm via axoupdater → axoasset).
  • Warm rebuild after editing src/: about 13s. The integration test crates never relink, because they use only the built binary.
  • Test run: spec_run 282s, merge_run 140s, release 110s, run_notification 98s, ci 56s, the rest under 40s each.
  • The time goes on starting processes, not on sleeps. Every scenario runs the real binary end to end, and it starts many git calls and Python fake gh/claude calls. The harness polls CI every 10ms, and each poll starts the fake gh again. Tests that loop over cases or over Repair cycles multiply this. Python startup is very sensitive to load (python3 -c pass: 0.48s wall for 0.04s of CPU).
  • There is no [profile.dev], so builds use full debuginfo: a 108MB binary and a 1.6GB target dir per worktree.

Solution

Make the suite cheaper to build and to run, and measure the result in a way that doesn't depend on machine load. A session can then run the full suite in the foreground again, and a fresh Run worktree becomes testable in a few minutes rather than ten. The full suite stays the local gate for sessions; this spec does not narrow what they run.

Done means all three:

  1. A warm full cargo test finishes in ≤ 3 minutes wall on an idle machine.
  2. The warm full suite's total CPU time (user + sys) is at most half of a baseline recorded before any change.
  3. A cold cargo test --no-run finishes in ≤ 5 minutes wall on an idle machine.

User Stories

  1. As an agent session in a Run, I want the full test suite to finish within one foreground Bash call, so that I never have to wait on a backgrounded test run.
  2. As an agent session in a Run, I want a fresh worktree to build its test targets in a few minutes, so that my first test run doesn't use up most of my time.
  3. As an agent session in a Repair, I want re-running the suite after a fix to be quick, so that I can verify each fix before pushing.
  4. As the maintainer running several Runs at once, I want each suite run to use less CPU, so that parallel Runs slow each other down less.
  5. As the maintainer, I want less disk used per Run worktree, so that many worktrees can live side by side.
  6. As the maintainer, I want the speed-up measured in CPU time as well as wall time, so that I can tell a real gain from a quiet machine.
  7. As the maintainer, I want a recorded baseline and after-numbers in the PR, so that I can see what each change bought.
  8. As the maintainer, I want the shipped binary to keep verifying TLS the way it does now, so that switching the crypto provider changes build time only, not behavior.
  9. As the maintainer, I want self-update (axoupdater) to keep working, so that trimming dependencies doesn't break thirdshift update.
  10. As a contributor, I want the end-to-end tests to keep checking the same behaviors, so that a faster suite doesn't mean weaker coverage.
  11. As a contributor, I want argument-parsing cases covered by fast tests, so that adding a case doesn't add a full end-to-end Run.
  12. As a contributor, I want the test fakes for gh and claude to stay easy to read and extend, so that new scenarios stay cheap to write.
  13. As a contributor, I want backtraces from a failing test to still show file and line, so that cheaper debug builds don't make failures harder to find.
  14. As CI, I want shorter build and test jobs, so that a Run's CI wait is shorter.

Implementation Decisions

Apply the levers cheapest first, measuring after each. Once all three targets are met, the rest are optional, but record what was skipped and why.

  • Baseline first. Before any change, record on the same machine: cold cargo test --no-run wall time, warm full cargo test wall time, and user + sys (e.g. /usr/bin/time), with the load average at the time. Report before and after in the PR.
  • Cheaper debug builds. Add a dev profile with debug = "line-tables-only" for the crate and no debuginfo for dependencies. Backtraces keep file and line; debugger locals are given up.
  • rustls crypto provider: ring, not aws-lc. Configure reqwest/rustls so aws-lc-sys is no longer built at all (check cargo tree -i aws-lc-sys comes back empty). TLS verification behavior stays the same.
  • axoupdater's image stack. Drop image/moxcms/pxfm only if axoupdater/axoasset offer a feature flag for it. If not, leave the dependency alone and note it in the PR as a follow-up (an upstream change or a replacement updater). Don't fork or replace axoupdater here.
  • Fewer processes per scenario (lever 1). The CI poll interval is already overridable through the existing environment variable the harness sets. Raise the harness value from 10ms to about 100ms, and adjust the CI grace period the harness sets if needed so CI-wait scenarios still see their states. No production change is needed.
  • Fewer end-to-end scenarios (lever 2). Where a test loops over cases that differ only in what they exercise below the Run (e.g. every position --email can take in the arguments), keep one end-to-end scenario and move the case list to unit tests of the argument parsing. The same goes for loops over signals or Repair and budget cycles, where one end-to-end case proves the wiring and the rest can be checked below the binary. Argument parsing has no unit tests today, so this adds that one new seam.
  • Cheaper fakes (lever 3, only if still needed). Replace the Python fake gh/claude with something cheaper to start, e.g. a small Rust helper binary built with the tests, keeping their current contract: state and records in the JSON files the harness names, and the same argv handling.
  • Don't merge the integration test files into one crate: they never relink when src/ changes, so there is little to gain.

Testing Decisions

  • A good test here checks external behavior: the real binary run end to end against the fakes, asserting on exit code, stderr, git state, and the requests the fakes record. It doesn't check how the Run does it internally.
  • The end-to-end harness stays the main seam. Every behavior an end-to-end test covers today must still be covered end to end or by a unit test at the level where the cases differ.
  • The only new seam is unit tests of argument parsing, alongside the existing unit-test modules in the crate (prior art: the in-crate unit tests for config, email, notification, progress and worktree).
  • If the fakes are rewritten, every existing scenario must pass unchanged against the new ones. They are the fakes' test.
  • The measurements (baseline and after) are the acceptance test for speed. Include them in the PR.

Out of Scope

  • Limiting how many builds run at once across Runs, and a Spec run's default parallelism (filed separately).
  • The flaky 10s "the agent never started" deadline in the harness (filed separately).
  • Narrowing what agent sessions run (targeted tests instead of the full suite).
  • Language-neutral speed-ups in thirdshift itself, e.g. a worktree-setup hook or cache sharing for the repos it runs on.
  • Changes to the CI workflow (caching, splitting jobs), beyond what it gains from a cheaper build and suite.
  • Replacing or forking axoupdater.
  • Merging integration test crates.

Further Notes

  • Grilled on 2026-09-30 with the maintainer. Settled: the main aim is agent sessions' wait, with CI second; scope is this repo's own suite; sessions keep running the full suite; the time on an idle machine and the halved CPU time are both targets.
  • Related: Sessions wait on a backgrounded command by its own task, not by process name #180 (sessions waiting on a backgrounded command by process name), which this makes rarer but doesn't replace.

Activity

  1. changed the title [-]make tests faster[/-] [+]make test runs faster[/+] on Sep 30, 2026
  2. JacobStephens2 commented on Sep 30, 2026

    @JacobStephens2
    OwnerAuthor

    This was generated by AI during triage.

    Triage Notes

    Categorized as enhancement and left in needs-triage. It needs a target and a decision on where to cut before an agent can take it.

    Evidence from the #148 Merge run (2026-09-30, running at the same time as the #147 and #158 runs):

    • cargo build alone took about 6 minutes (11:45→11:51Z).
    • fmt, clippy and cargo test --test spec_run took about 6 more minutes (11:51→11:57Z).
    • The full cargo test went past the Bash tool's 600s limit, so it was moved to the background and finished at about 12:08Z. The prompts ask sessions to run tests in the foreground, and that is no longer possible when several sessions run at once. It also led to the session wasting about 7 minutes on a bad wait (filed separately).
    • The suite has about 21 test binaries. Each integration test file under tests/ is its own crate, linked separately against the full dependency tree (reqwest/aws-lc-sys and others). Under contention, that link and build cost is probably most of the time, more than the tests themselves.

    Still to decide (maintainer):

    • A target, e.g. a full cargo test from a warm cache in under N minutes with 3 sessions running at once.
    • Where to cut. Candidates: merge the integration test files into one test crate (one link instead of about 20), share a CARGO_TARGET_DIR or sccache across worktrees so each Run doesn't rebuild dependencies from scratch, trim the dependency features the test build pulls in, or look at the slowest individual tests (e.g. merge_run, spec_run).
  3. changed the title [-]make test runs faster[/-] [+]Make the test suite faster[/+] on Sep 30, 2026
  4. added
    ready-for-agentFully specified, ready for an AFK agent
    and removed
    needs-triageMaintainer needs to evaluate this issue
    on Sep 30, 2026
  5. JacobStephens2 commented on Sep 30, 2026

    @JacobStephens2
    OwnerAuthor

    Baseline

    Recorded for #202 on 2026-09-30, before any change (main at 55bcf6e). Every later ticket under this spec compares against these numbers.

    Machine: 4 cores, Linux 6.8.0, rustc 1.98.1. Other Runs were active on the machine, so the load was not idle. Load is the 1-minute load average, sampled every 5s during each command.

    Measurement Wall User + sys Load (mean / max)
    Cold cargo test --no-run (empty target dir) 127s 287s + 50s = 336s 3.54 / 4.53
    Warm full cargo test 268s 479s + 222s = 701s 5.07 / 5.78

    Also: target/ is 1.5GB after the cold build, and the debug binary is 108MB.

    Commands: /usr/bin/time -f "wall=%e user=%U sys=%S" cargo test --no-run from an empty target/, then /usr/bin/time … cargo test straight after it.

  6. JacobStephens2 commented on Sep 30, 2026

    @JacobStephens2
    OwnerAuthor

    This was generated by AI while implementing #203.

    Follow-up from #203: aws-lc and the image stack both stay

    Neither stack can be dropped with a feature flag. Both come in through axoupdater:

    • axoupdater 0.10.2 (the latest) has github_releases = ["axoasset/remote"]. It also uses axoasset::reqwest with no cfg gate, so it won't compile without remote.
    • axoasset 2.0.1 (the latest) has remote = ["reqwest", "image"], and it depends on reqwest with features = ["json", "default-tls"] fixed in place. No axoasset feature leaves either out.
    • In reqwest 0.13, default-tls = ["rustls"] and rustls includes __rustls-aws-lc-rs. Features unify, so switching thirdshift's own reqwest to rustls-no-provider plus rustls's ring would still build aws-lc (checked with cargo tree -i aws-lc-sys), and would build ring on top of it.

    What would remove them (all out of scope for #203):

    • Upstream axoasset: split image out of remote, and depend on reqwest with rustls-no-provider (or offer a TLS-provider feature) instead of default-tls. That removes both stacks with no change here beyond a version bump.
    • A replacement updater: thirdshift already has blocking reqwest. A small updater that fetches the latest GitHub release and runs the installer would drop axoupdater, axoasset, image/moxcms/pxfm and aws-lc together, and our reqwest could then move to ring.
    • A [patch.crates-io] on axoasset would work locally but is a fork, and it doesn't apply to cargo install from crates.io.
    • Going back to axoupdater 0.9 / axoasset 1.x (reqwest with rustls-tls-webpki-roots) is a downgrade and still brings in image.

    #203 records this in a Cargo.toml comment so nobody tries the flag route again.

  7. JacobStephens2 commented on Sep 30, 2026

    @JacobStephens2
    OwnerAuthor

    This was generated by AI while implementing #207.

    Result

    All three targets are met. The Python fakes were the last big cost: before #207 they used about 352s of the warm suite's 650s CPU (4,940 gh calls, 381 claude calls). #207 replaces them with a small std-only Rust executable that the tests build with rustc.

    Machine: 4 cores, Linux 6.8.0, rustc 1.98.1. None of these runs was on an idle machine. Other Runs were active, and the load is the 1-minute load average, sampled every 5s during each command. User + sys doesn't depend on load much, and even the wall times are under target under this load.

    Measurement Target Baseline (55bcf6e) Before #207 (9b1b1e0, #203–#206 in) After #207
    Warm full cargo test, wall ≤ 180s 268s 249s 107s / 129s / 113s
    Warm full cargo test, user + sys ≤ 350s (half of 701s) 479 + 222 = 701s 444 + 205 = 650s 57 + 131 = 188s / 59 + 142 = 202s / 67 + 139 = 206s
    Cold cargo test --no-run, wall ≤ 300s 127s (336s CPU) 111s (290s CPU) 108s (279s CPU)
    Load, mean / max warm 5.07 / 5.78, cold 3.54 / 4.53 warm 4.89 / 5.42, cold 3.39 / 4.41 warm 3.59 / 4.72, 4.19 / 5.52, 3.59 / 4.78; cold 4.44 / 4.81

    The three "after" warm runs are the same code, run one after another. The second and third ran with a python3 stub first on PATH that fails if called. Everything passed, and nothing called it.

    The cold --no-run no longer covers everything a fresh worktree builds: the first test that needs the fakes builds them with rustc (about 1.2s wall, 1.7s CPU). They're then cached in target/tmp/fakes-<hash of their sources>.

    Where the remaining time goes. No target is missed. Of the roughly 200s CPU left, about two thirds is sys time, spent starting processes: the real binary, git, and the bash scripts that stand in for the agent. The slowest binaries are spec_run (35s), merge_run (14s) and release (14s). Everything else is under 5s.

    Commands: /usr/bin/time -f "wall=%e user=%U sys=%S" cargo test --no-run from an empty target dir, and /usr/bin/time … cargo test on a warm one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestready-for-agentFully specified, ready for an AFK agent

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions