diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b752592..fb3657f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,11 @@ jobs: **/*.md !**/target/** !**/dist/** + - name: Install interrogate + run: | + python -m pip install --user uv==0.11.19 + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + "$HOME/.local/bin/uv" tool install interrogate==1.7.0 - name: Lint run: make lint - name: Install cargo-nextest @@ -49,6 +54,11 @@ jobs: format: lcov features: dev-worker with-default-features: "false" + - name: Loom Concurrency Tests (non-blocking) + if: ${{ always() && matrix.privilege == 'unprivileged' }} + run: make test-loom + timeout-minutes: 1 + continue-on-error: true - name: Install cargo-nextest and test (root) if: ${{ matrix.privilege == 'root' }} run: | diff --git a/.gitignore b/.gitignore index 1f7152b9..d45563a4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target/ **/*.rs.bk .grepai/ +.memdb/ diff --git a/Makefile b/Makefile index f17d9b14..c686bd84 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help all clean test build release release-archive lint fmt check-fmt markdownlint nixie typecheck +.PHONY: help all clean test test-loom build release release-archive lint fmt check-fmt markdownlint nixie typecheck APP ?= pg_embedded_setup_unpriv CARGO ?= cargo @@ -26,6 +26,8 @@ CLIPPY_FLAGS ?= --all-targets --all-features -- -D warnings RUSTDOC_FLAGS ?= --cfg docsrs -D warnings MDLINT ?= markdownlint-cli2 NIXIE ?= nixie +INTERROGATE ?= interrogate +PY_DOCSTRING_COVERAGE ?= 100 build: ## Build debug binary $(CARGO) build $(BUILD_JOBS) --bin "$(APP)" @@ -43,6 +45,9 @@ test: ## Run tests with warnings treated as errors RUSTFLAGS="-D warnings" $(CARGO) nextest run --all-targets --all-features $(BUILD_JOBS) RUSTFLAGS="-D warnings" $(CARGO) nextest run --tests --workspace --no-default-features --features dev-worker $(BUILD_JOBS) +test-loom: ## Run Loom concurrency tests + $(CARGO) test --features "loom-tests" --lib -- --ignored + release-archive: ## Package release binaries for cargo-binstall @test -n "$(TARGET)" || (echo "TARGET is required" >&2; exit 1) @test "$(MANIFEST_VERSION)" = "$(VERSION)" || \ @@ -57,6 +62,7 @@ release-archive: ## Package release binaries for cargo-binstall rm -rf "$(RELEASE_ARCHIVE_DIR)" lint: ## Run Clippy with warnings denied + $(INTERROGATE) --fail-under $(PY_DOCSTRING_COVERAGE) . RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" $(CARGO) doc --workspace --no-deps $(BUILD_JOBS) $(CARGO) clippy $(CLIPPY_FLAGS) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 311bdd1e..a161b2dc 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -38,13 +38,19 @@ can install those published assets on Linux `x86_64` and `aarch64`. Loom-based checks for `ScopedEnv` are opt-in and only compile when the `loom-tests` feature is enabled. The Loom tests are marked `#[ignore]`, and `make test` keeps them dormant: the nextest run uses `--all-features`, while -the follow-up `cargo test` run disables default features (enabling `dev-worker` -only). Run the Loom suite with: +the follow-up `cargo nextest run` disables default features (enabling +`dev-worker` only). Run the Loom suite locally with: ```sh -cargo test --features "loom-tests" --lib -- --ignored +make test-loom ``` +The scheduler budget in `src/env/loom_tests.rs` currently uses +`max_threads = 3`, `max_branches = 64`, and `preemption_bound = Some(3)`. The +three bounds jointly constrain the search space so the suite stays tractable. +Changing any of them requires justification, and may need matching CI timeout +adjustments. + ## Further reading - `tests/e2e_postgresql_embedded_diesel.rs` – example of combining the helper diff --git a/src/env/loom_tests.rs b/src/env/loom_tests.rs index 4d9646ff..b760a61e 100644 --- a/src/env/loom_tests.rs +++ b/src/env/loom_tests.rs @@ -12,17 +12,20 @@ loom::lazy_static! { static ref LOOM_ENV_LOCK: loom::sync::Mutex<()> = loom::sync::Mutex::new(()); } +/// Provides the Loom-backed environment lock used by these model checks. struct LoomEnvLock; impl EnvLockOps for LoomEnvLock { type Guard = loom::sync::MutexGuard<'static, ()>; + /// Acquires the modelled environment mutex for a scoped environment guard. fn lock_env_mutex() -> Self::Guard { LOOM_ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } + /// Leaves poisoning recovery to Loom's modelled mutex implementation. fn ensure_lock_is_clean() {} } @@ -31,6 +34,7 @@ loom::thread_local! { RefCell::new(ThreadStateInner::new()); } +/// Enters a scoped environment frame using Loom thread-local state. fn enter_scope_loom(vars: Vec<(OsString, Option)>) -> usize { LOOM_THREAD_STATE.with(|cell| { let mut state = cell.borrow_mut(); @@ -38,6 +42,7 @@ fn enter_scope_loom(vars: Vec<(OsString, Option)>) -> usize { }) } +/// Exits a scoped environment frame from Loom thread-local state. fn exit_scope_loom(index: usize) { LOOM_THREAD_STATE.with(|cell| { let mut state = cell.borrow_mut(); @@ -45,6 +50,7 @@ fn exit_scope_loom(index: usize) { }); } +/// Applies test environment changes through the Loom state hooks. fn apply_loom(vars: &[(String, Option)]) -> ScopedEnv { let owned: Vec<(OsString, Option)> = vars .iter() @@ -53,17 +59,22 @@ fn apply_loom(vars: &[(String, Option)]) -> ScopedEnv { ScopedEnv::apply_owned_with_state(owned, enter_scope_loom, exit_scope_loom) } +/// Runs a bounded Loom model for the scoped environment lock scenarios. fn run_loom_model(f: F) where F: Fn() + Send + Sync + 'static, { let mut builder = loom::model::Builder::new(); + // These bounds keep the scheduler search tractable enough for routine CI. + // Increasing the preemption bound in particular needs a matching runtime + // budget review; see the developer guide for details. builder.max_threads = 3; builder.max_branches = 64; builder.preemption_bound = Some(3); builder.check(f); } +/// Verifies that concurrent scoped environments cannot overlap. #[test] #[ignore = "requires Loom model checking"] fn scoped_env_serialises_concurrent_scopes() { @@ -95,6 +106,7 @@ fn scoped_env_serialises_concurrent_scopes() { }); } +/// Verifies that nested scopes on one thread keep the lock reentrant. #[test] #[ignore = "requires Loom model checking"] fn scoped_env_allows_reentrant_scopes_on_one_thread() { diff --git a/tests/test_workflow_integration.py b/tests/test_workflow_integration.py index 6f7b7d5a..4b09f35e 100644 --- a/tests/test_workflow_integration.py +++ b/tests/test_workflow_integration.py @@ -17,6 +17,7 @@ def run_act( *, artifact_dir: Path, ) -> tuple[int, Path, str]: + """Run an `act` job and return its exit code, artefact directory, and logs.""" if shutil.which("act") is None: pytest.skip("act CLI not installed") artifact_dir.mkdir(parents=True, exist_ok=True) @@ -52,6 +53,7 @@ def run_act( def test_workflow_produces_expected_artefact_and_logs(tmp_path: Path) -> None: + """Verify the self-test workflow writes its artefact and greeting logs.""" artifact_dir = tmp_path / "act-artifacts" code, artdir, logs = run_act(artifact_dir=artifact_dir) assert code == 0, f"act failed:\n{logs}"