Skip to content

Parallelize test suite with pytest-xdist - #3353

Open
singlerider wants to merge 14 commits into
blacklanternsecurity:devfrom
singlerider:ci-parallel-tests
Open

Parallelize test suite with pytest-xdist#3353
singlerider wants to merge 14 commits into
blacklanternsecurity:devfrom
singlerider:ci-parallel-tests

Conversation

@singlerider

@singlerider singlerider commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Runs the test suite under pytest-xdist. On a 4-core runner this takes CI from about two hours to about half an hour, with the same tests passing.

wall passed failed error skipped
dev (serial) 124m20s 791 1 8 31
this branch (4 workers) 33m44s 791 1 8 31

Pass counts are identical, which is the point. Wall time drops about 3.7x on that pair of runs, but the number that matters is that the same 791 tests pass either way.

The 1 remaining failure (test_cli.py::test_cli_args) and all 8 errors (gowitness, jadx, unarchive) reproduce on unmodified dev in the same environment. They are missing binaries in my local runner container, not something this branch introduces.

Validation

Both sides were run as the real Tests/test workflow job under act, not a bare pytest invocation, so the comparison includes the same dependency installs, service containers and workflow steps CI actually performs:

act pull_request -j test -W .github/workflows/tests.yml \
    --matrix python-version:3.14 \
    -P ubuntu-latest=catthehacker/ubuntu:act-latest \
    --env BBOT_TEST_WORKERS=4

Same machine, same container image, same Python (3.14.6), pytest 9.1.1, pytest-xdist 3.8.0. Baseline is dev at fab03bed1, checked out in a separate worktree and run unmodified.

Final run

before (serial, dev @ fab03bed1):  7460s = 124m20s   791 passed  1 failed  8 error  31 skipped
after  (4 workers, this branch):   2024s =  33m44s   791 passed  1 failed  8 error  31 skipped

Measured against 5ce87d99c, which was the branch tip at the time.

The branch has since been rebased onto current dev and the numbers above have not been re-measured on the rebased tree. The original branch was cut from fab03bed1 and had drifted 24 commits behind; left alone it would have reverted actions/setup-python v7 to v6 across four workflows and narrowed the pytest-env and fastapi bounds back down, purely as rebase collateral. The rebase was clean and the intervening commits are dependency bumps plus a docker_pull fix, none of which plausibly move a 2-hour wall time, but the honest statement is that the measurement predates the rebase.

Pass counts match exactly. The failing test and the 8 errors are the same node IDs on both sides:

  • test_cli.py::test_cli_args (fails on unmodified dev here as well)
  • 5x test_module_gowitness.py, 2x test_module_unarchive.py, 1x test_module_jadx.py — missing binaries in my runner container (7z and friends), unrelated to this change

Diffing the before/after failure and error sets yields zero regressions.

Convergence across runs

The parallel side was re-run after each round of fixes. Included because the first numbers were not clean and it would be misleading to show only the last:

run wall passed failed error notes
1 39m05s 770 19 11 before the fixes below; not quotable
2 27m26s 757 11 36 network outage mid-run, filedownload could not fetch its wordlist
3 30m39s 786 6 8 errors now match baseline
4 23m43s 796 3 1 best case, lucky network
5 29m40s 791 1 8 matches baseline
6 33m44s 791 1 8 after the cleanup commit

Runs 4-6 differ only in how the dependency downloads went, which is why ~30m is the figure worth quoting rather than 23m43s. Across the three clean runs the spread is 23m43s to 33m44s, all with the same pass count, so the speedup is somewhere in the 3.7x-5.2x range depending on network luck. The serial baseline is a single 124m sample; I did not re-run it, so treat it as one observation.

port collisions: 0 in every run, grepped from the logs as an explicit check rather than inferred from the pass count.

Per-test verification

Each fix was confirmed individually at 4 workers before the full re-run, and flaky-looking failures were repeated 3-4 times rather than declared fixed on a single green run. Two examples:

  • test_module_robots and test_cli_presets each failed roughly 1 run in 4. Both were run 4x after the fix, 4/4 green.
  • test_module_trufflehog looked like a race. It reproduced at -n 1 — one worker, no concurrency — which showed it was the per-worker home rename, not a race. Worth knowing when triaging anything similar: if it fails at -n 1 it is an isolation bug, not a concurrency bug.

Why 4 workers

CI is ubuntu-latest, 4 cores, so 4 workers is the number that predicts CI time. My box has 16 cores but roughly a quarter the RAM per core, so a 16-way measurement here would describe hardware nobody runs the suite on.

How it works

Each xdist worker gets its own:

  • BBOT home directory (/tmp/.bbot_test_gw0), since pytest_sessionfinish deletes it and whichever worker finished first was deleting it out from under the others
  • port block for the test HTTP servers, so workers do not fight over 8888. Base ports are spaced by a stride of 100, which keeps every worker's block disjoint well past any worker count we would actually use.

Serial runs are unchanged: no worker id means the original path and the original ports, so running pytest without -n behaves exactly as before.

Docker-backed tests (kafka, elastic, mongo, mysql, nats, postgres, rabbitmq) bind fixed container names and host ports, so they are pinned to a single worker via xdist_group. They are identified by the skip_distro_tests class attribute they already set.

Notable fixes along the way

  • Workers had logging silently disabled. They inherit _BBOT_LOGGING_SETUP from the parent, so BBOTLogger skipped setup and debug.log was never written; tests reading it failed against an empty file. conftest now raises if it ever finds itself without a real listener, rather than proceeding with logging quietly broken.
  • Unbounded container waits. The elastic/mongo readiness loops were while True. When a container was OOM-killed the suite hung until the global timeout rather than reporting anything useful. Now bounded, with a hint about exit 137.
  • Worker count is capped by memory as well as cores, so a RAM-poor machine does not spawn 16 workers and OOM its own containers. No effect on a 4-core runner.
  • Several tests hardcoded 8888 or .bbot_test in places a naive search misses: filenames, path slugs, regex literals, bare integer comparisons, and mocked tool output. The last one of these was a canary regex in test_module_virtualhost that still matched a literal 8888, so on any worker but gw0 it silently stopped matching. It did not show up as a failure because that branch and the fallthrough both return 404 — worth calling out, since a green run did not prove that particular line correct.
  • TestAsset_InventoryEmitPrevious depended on a sibling test having run first to produce its input CSV. It now seeds its own, which also drops it from ~20s to ~4s.

Measured on real CI

The numbers above come from my own machine under act. Now that both a parallel and a serial run have completed on GitHub's own runners, here is the honest comparison, per Python version, same workflow, same ubuntu-latest hardware:

python serial (#3355 branch) parallel (this branch)
3.10 59m 22m
3.11 57m 23m
3.12 53m 23m
3.13 53m 20m
3.14 46m 17m

Whole-workflow wall time: 60m serial, 24m parallel.

That is about 2.6x, noticeably less than the 3.7x I measured locally. A 4-core runner does not give 4x, and the local figure was flattered by my own hardware. 2.6x on the actual runners is the number worth quoting, and it still takes the matrix from an hour to under half an hour.

The serial side is the #3355 branch, which changes only workflow YAML and no Python, so it is a valid stand-in for dev's serial timing.

Bugs CI caught that local runs did not

Worth stating plainly, because it is the main risk in a change like this: two rounds of fixes here came from CI, not from my own runs.

TestGowitness_Social errored on all five Python versions with a FileNotFoundError under /tmp/.bbot_gowitness_test/cache/depsinstaller/.... Two tests shared one BBOT home, so their dependency installers raced over the same artifacts directory. It never reproduced locally because gowitness's binary is absent on my machine, so both tests errored out early and never got far enough to collide.

That prompted a sweep for the same shape of bug rather than just fixing the one instance. Four more fixed paths were shared across workers: the test_python_api BBOT home (same dep-installer race, two tests), the test_presets custom preset dir, and the scratch files in test_command and test_web that are written and then read back. All now carry a per-worker name.

The general lesson: a hardcoded path in a test is only safe while the suite is serial, and a locally-green run does not prove otherwise when a missing binary is masking the code path.

CI status on this PR

  • benchmark fails with a 403 posting its comment. That is a fork PR getting a read-only GITHUB_TOKEN; it fails the same way on the other two CI PRs and passes on same-repo branches. Not something this branch can fix.
  • One test-distros (debian) run failed on test_cli_args, the same test that already fails on dev in a container missing binaries. The distro suite passed on this branch on earlier pushes, and dev itself had a distro job go red on 07-27, so this is the existing flakiness rather than a regression. The distro workflow is untouched here and still runs serially.

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.03962% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 90%. Comparing base (cccc263) to head (deb6bdd).

Files with missing lines Patch % Lines
bbot/test/conftest.py 77% 3 Missing ⚠️
bbot/test/test_step_1/test_bbot_fastapi.py 80% 1 Missing ⚠️
bbot/test/test_step_1/test_worker_count.py 99% 1 Missing ⚠️
...est_step_2/module_tests/test_module_virtualhost.py 98% 1 Missing ⚠️
bbot/test/worker.py 99% 1 Missing ⚠️
bbot/test/worker_count.py 95% 1 Missing ⚠️
Additional details and impacted files
@@          Coverage Diff           @@
##             dev   #3353    +/-   ##
======================================
+ Coverage     90%     90%    +1%     
======================================
  Files        450     454     +4     
  Lines      46327   46588   +261     
======================================
+ Hits       41587   41851   +264     
+ Misses      4740    4737     -3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Test servers bound fixed ports (8888/9999/5556) and 58 test files hardcoded
those in URLs, so the suite could only ever run serially. CI spent ~58 min in
a single pytest call while using one of four cores.

- bbot/test/ports.py: resolve server ports per xdist worker
- conftest.py: tolerate listener=None (workers inherit _BBOT_LOGGING_SETUP)
- 57 test files: hardcoded host:port -> constants from bbot.test.ports

localhost and 127.0.0.1 spellings are kept distinct; virtualhost tests assert
on the Host header, where they are not interchangeable. test_regexes.py is
untouched, its literals are regex fixtures, not references to the test server.
Beyond the server ports, three things were process-global and would corrupt
each other under xdist:

- BBOT home (/tmp/.bbot_test): shared caches and scan output, and
  pytest_sessionfinish rmtree's it. The first worker to finish would delete
  the directory out from under every worker still running.
- uvicorn (8978) and websockets (8765) servers: bound fixed ports.
- docker-backed tests (kafka, elastic, mongo, mysql, nats, postgres,
  rabbitmq): fixed container names and fixed host port bindings, so they
  genuinely cannot run concurrently. They are now pinned to a single worker
  via xdist_group, keyed off the skip_distro_tests flag they already set.

Renamed ports.py -> worker.py since it now covers more than ports. Port bases
were chosen with distinct mod-100 residues; verified collision-free and clear
of the ephemeral range for up to 64 workers.
pytest-xdist added to the dev group; workflow runs with -n logical
--dist loadgroup. loadgroup (not loadfile) is required so the xdist_group
marker on the docker-backed tests is honored.
Two problems surfaced running the suite 16-way on a 16-core/15GB box.

Elasticsearch was OOM-killed (exit 137): 16 workers hold ~10GB, leaving
under 3GB, and it wants ~2GB of heap. The test then waited for it in an
unbounded 'while True', so a container that was already dead hung the run
at 99% until the 1200s global timeout. Same unbounded loop in the mongo
test. Both now stop after CONTAINER_READY_TIMEOUT (180s, overridable) and
say what happened, including the exit-137 hint.

'-n logical' is the wrong worker count when cores outnumber GB. worker_count.py
bounds by cores AND by available memory, keeping 5GB free for the docker-backed
services. A 4-core/16GB github runner is unaffected (still 4); this box goes
16 -> 15. Override with BBOT_TEST_WORKERS.
Running the suite under xdist surfaced failures that were my own, not
pre-existing. Four separate gaps:

Logging. Workers inherit _BBOT_LOGGING_SETUP from the pytest parent, so
BBOTLogger skipped setup entirely and debug.log was never written. The
previous 'if listener is not None' guard hid that instead of fixing it,
and log-reading tests failed with 'assert -retries 1 in ""'. Workers now
clear the flag so each sets up its own logging, and conftest raises if a
process somehow still has no listener.

Port literals the first pass did not match, because it only looked for
127.0.0.1:PORT and localhost:PORT:
  * synthetic hostnames carrying the port (test.example:8888, bad.dns:8888)
  * the bbot_other_httpservers fixture, six servers hardcoded to 8888/8889
  * config values like {"ports": "8888"}, which are not URLs
  * 127.0.0.0:9999 (the .0 address, excluded along with .1)
  * filename/slug spellings: 127.0.0.1.8888 and http-127-0-0-1-8888
  * mocked masscan JSON, whose port is what the module 'discovers'

Hardcoded /tmp/.bbot_test paths, which my own per-worker home broke.

Adds BBOT_TEST_PORT_OFFSET so a second copy of the suite can run beside
one that already owns the base ports, instead of the two colliding and
looking like a worker-isolation bug.
Full-suite A/B surfaced the last regressions from parallelization.

asset_inventory: TestAsset_InventoryEmitPrevious runs with use_previous=True,
which reads the asset-inventory.csv left behind by an earlier scan of the same
name. It was relying on TestAsset_Inventory having already run. That is
order-dependent, and xdist guarantees neither order nor worker affinity, so it
failed whenever it was scheduled first or landed on another worker (each worker
has its own BBOT home). It now seeds the CSV itself in setup_before_prep, which
makes it independent and drops its runtime from ~20s to ~4s since it no longer
needs the portscan.

Port comparisons written as bare integers (e.port == 8888) in test_scope.py and
test_module_fingerprintx.py. The earlier passes only rewrote string literals, so
these were missed.

Also groups tests that share a scan_name onto one worker, alongside the existing
docker grouping. Not sufficient on its own for the ordering problem above, but
correct for co-locating tests that share a scan home.
git_clone writes cloned repos to git_repos/<bbot home name>/<repo>, so the
home directory's *basename* shows up inside the paths these tests assert on.
They spelled it '.bbot_test', which stopped matching once each xdist worker
got its own home ('.bbot_test_gw0').

Export BBOT_TEST_DIR_NAME from worker.py and use it instead.

Worth noting these reproduce with '-n 1', a single worker with no concurrency
at all. They were never race conditions, just the per-worker home rename, and
checking '-n 1' before '-n 4' would have found that much faster.
test_module_robots matched the server URL with a raw regex string, where the
port sits behind escaped dots (r"http://127\.0\.0\.1:8888/..."). The earlier
rewrites looked for plain literals and skipped it, so the test failed whenever
it landed on a worker whose port was not 8888.

test_cli_presets asserts a scan output dir does not exist yet, and removes it
at the end of the test. Any earlier failure or interrupted run leaves it in
place, and every later run against that BBOT home then fails on the assertion
rather than on the real problem. It now clears the directory up front, so the
precondition is established instead of assumed.

Both showed up as intermittent (~25% of runs) rather than deterministic, which
is what made them the last two to be found.
Net removal pass over the parallelization work.

Removed:
- BBOT_TEST_PORT_OFFSET and port_offset(). Added for running two copies of the
  suite side by side while benchmarking. Nothing outside worker.py used it.
- LOCALHOST_SSL_HOSTPORT, the BASE_* port aliases, and the worker_index()
  wrapper. All unused or single-use indirection.
- OTHER_HTTPSERVER_PORT, which was literally 'OTHER_HTTPSERVER_PORT =
  HTTPSERVER_PORT'. Renamed to HTTPSERVER_PORT/HTTPSERVER_PORT_ALT at its one
  call site.
- The scan_name xdist grouping. It compensated for asset_inventory's ordering
  dependency, which the previous commit removed by having the test seed its own
  input, so the grouping no longer does anything. Verified: 3x green at 4
  workers without it.
- worker_count's -v flag and MIN_WORKERS floor. The floor guaranteed 2 workers
  even when the memory arithmetic said there was room for none, which is an
  assumption I never validated; a 2-core/4GB box now correctly gets 1.
- A dead 'import time' the refactor exposed.

De-duplicated:
- The container readiness loop was copy-pasted between elastic (sync) and mongo
  (async), each with its own deadline handling and OOM hint. Now one
  wait_for_container() helper that takes either kind of callable.

worker.py 154 -> 100 lines, worker_count.py 64 -> 38, conftest hook 40 -> 13.
Caught by CI on this PR, not locally: gowitness needs its binary installed, so
both classes error out on my machine and never reached the assertions.

Two bugs:

The multiport test asserted on bare port substrings ('8888' in url) rather than
URL-shaped literals, so the earlier rewrites did not match them. On any worker
but the first the ports are 9188/10299 and the assertion could never pass.

Both classes also pinned an explicit home directory under /tmp and rmtree'd it
at class-definition time, which every worker does to the same path. Workers
deleted each other's dependency installs mid-run, which is where the
FileNotFoundError on the depsinstaller artifact came from. Now worker-scoped.

Worth noting these run with deps.behavior=force_install, so each worker
installs its own copy of chrome, ~650MB. That is fine on a runner where /tmp is
disk-backed, but it multiplies with worker count.
The force_basehost wildcard-canary check still matched a literal 8888, so on
any worker but gw0 it never matched. Both that branch and the fallthrough
return 404, which is why it did not show up as a failure.
Same class of bug as the gowitness home directory that CI caught: paths that
are fine serially but shared once workers run concurrently.

- test_python_api used one BBOT home across two tests, so the dep installer
  wrote and read the same artifacts directory from two workers
- test_presets wrote a fixed custom preset dir
- test_command and test_web wrote fixed scratch files, then read them back
A pull_request run from a fork gets a read-only GITHUB_TOKEN regardless of the
permissions block, so github-script fails with

  HttpError: Resource not accessible by integration (403)
  POST /repos/.../issues/{n}/comments

and takes the whole job red. Every fork PR has a failing Performance Benchmarks
check for a reason that has nothing to do with performance, which trains people
to ignore a red check.

Skip the comment step when the head repo is not this repo. The benchmark still
runs and the report is still uploaded as an artifact, so nothing is lost except
a comment that could never have been posted.
@singlerider
singlerider marked this pull request as ready for review July 28, 2026 23:28
@singlerider
singlerider requested a review from liquidsec July 28, 2026 23:28
Codecov flagged worker_count.py at 0 percent: the workflow runs it as a script,
so pytest never imported it. worker.py was only covered incidentally.

These two modules decide how many workers run and how each one gets its own
ports and directories, so a silent regression there either overloads the runner
or lets workers collide.

Verified by mutation: removing the max(1, ...) clamp, dropping the memory cap,
and swapping sched_getaffinity for os.cpu_count each fail these tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants