Repository navigation
Test WorkerHooks::notify_drained_if_empty directly to catch negation mutants #153
Description
Context
Scheduled mutation run 29495843050 (2026-07-16, first green run after the baseline fix in #140) exercised 22 mutants across crates/comenqd/src/listener.rs, crates/comenqd/src/logging.rs, and crates/comenqd/src/worker.rs: 4 caught, 3 missed, 4 timed out, 11 unviable.
Two of the three missed mutants are negations of the same predicate in WorkerHooks::notify_drained_if_empty:
crates/comenqd/src/worker.rs:94:25: delete ! in WorkerHooks::notify_drained_if_empty
crates/comenqd/src/worker.rs:96:26: delete ! in WorkerHooks::notify_drained_if_empty
#[cfg(any(test, feature = "test-support"))]
fn notify_drained_if_empty(&self, queue_path: &Path) -> std::io::Result<()> {
if let Some(n) = &self.drained {
// Ignore sentinel files left by the queue implementation and
// consider the directory empty when no other files remain.
let empty = !stdfs::read_dir(queue_path)?
.filter_map(Result::ok)
.any(|e| !is_metadata_file(e.file_name()));
if empty {
n.notify_one();
}
}
...
}Analysis
This helper is cfg-gated to test and test-support builds, and exists specifically so integration tests can wait for the queue to drain. Because it is test synchronisation machinery, an inverted empty calculation would not fail functionally — it would either notify on every poll (masking a real drain) or never notify (causing waiters to hang or fall back to a timeout), which is exactly the kind of failure that manifests as flaky or slow tests rather than a clean assertion failure. That is consistent with it going undetected by the current suite: nothing exercises the predicate directly, only indirectly through tests that use the notification as one of several synchronisation signals.
Proposed next step
Add a direct unit test for notify_drained_if_empty that covers: an empty directory (should notify), a directory containing only sentinel/metadata files (should notify), and a directory containing at least one non-metadata file (should not notify). This directly exercises the !...any(...) predicate and its negation, independent of the higher-level integration tests that currently rely on it only incidentally.
Activity
coderabbitai commentedon Jul 20, 2026
🔗 Related PRs
leynos/whitaker#235 - Implement rstest helper call-site collection (8.2.2) [open]
leynos/netsuke#410 - Adopt the Whitaker Dylint suite in the lint gate and CI [merged]
leynos/diesel-cte-ext#99 - Skip mutation of the non-postgres SEARCH clause gate (
#92) [merged]leynos/pg-embed-setup-unpriv#186 - Restore dev-worker coverage ratchet with unprivileged unit tests [merged]
leynos/wireframe#612 - Kill mutation-testing survivors and widen scaffolding excludes (
#566,#567,#569,#570,#593,#595,#598) [merged]📝 Issue Planner
Check the box below or use the
@coderabbitai plancommand to generate an implementation plan and prompts that you can use with your favorite coding assistant.🧪 Issue enrichment is currently in open beta.
You can configure auto-planning by selecting labels in the issue_enrichment configuration.
To disable automatic issue enrichment, add the following to your
.coderabbit.yaml:💬 Have feedback or questions? Drop into our discord!
leynos commentedon Oct 20, 2026
@CodeRabbit plan
coderabbitai commentedon Oct 20, 2026
Coding Plan
Summary
claim_next_due()Nonebranch inrun_workerand thenotify_drainedhook. Do not restore deleted helpers.worker/tests/scheduling/. It tests hook delivery directly. It drives the real worker for the empty, metadata-only-after-drain and deferred-entry scenarios.Notifypermits withWaker::noop()instead of timeouts.cargo mutantsrun onworker.rs, and the fullmakegates.File-level changes
crates/comenqd/src/worker/tests/scheduling.rsmod drained;crates/comenqd/src/worker/tests/scheduling/drained.rscrates/comenqd/src/worker.rsdraineddoc wording ("queue is empty") untouched.Out of scope: restoring
notify_drained_if_emptyoris_metadata_file. Changing the drained trigger semantics. Rewording hook documentation. Testing the claimed-entry rule at the worker boundary. Fixing the other missed mutants from run 29495843050.Prompts for follow-up agents
Design Choices
Design Choice 1: How to treat the ticket's reference to a helper that no longer exists
Options Considered:
notify_drained_if_emptyandis_metadata_file, then test them as the ticket describes.Chosen Option: 3
Rationale: The ticket's goal is to catch silent drain-signal mutants, and the current
notify_drainedpath has the same coverage gap. Git history has one squashed commit, so the removal ofnotify_drained_if_emptycannot be traced. The current worker usesclaim_next_due()instead of a filesystem scan.Design Choice 2: How to make the drain decision directly testable
Options Considered:
run_workerand unit-test it.drainedpermit state after the worker stops.Chosen Option: 2
Rationale: This option needs no production refactor, and it exercises the real
claim_next_due()boundary that decides the drain. The decision is inline inrun_worker, and no standalone predicate exists.Design Choice 3: How to cover the ticket's "metadata-only directory" scenario
Options Considered:
.tmpfile inentries/by hand.Chosen Option: 2
Rationale: Real operations write valid metadata contents, and the store does not fail on unparseable files. The current store excludes metadata by directory layout, not by filename.
Design Choice 4: Whether to cover the "claimed entry" case at the worker boundary
Options Considered:
store/tests/claims.rsfor the store-level rule.Chosen Option: 2
Rationale: Worker-level setup interacts with recovery and adds no extra mutant coverage. It would also pin semantics that differ from the documented "queue is empty" wording. Note:
next_due()returnsNonewhen any entry holds a claim token, so the drained hook fires while entries still exist. Startup recovery can also release claims before the first loop.💡 User Tips
Regenerate the plan with different choices with
@coderabbitai <feedback>.Implementation Steps
Phase 1: Verify the target and the mutant set
Confirm the current code shape and list the mutants that the new tests must kill. This prevents tests aimed at stale symbols.
Task 1.1: Confirm the drain path and enumerate the worker mutants
Establish the exact mutants that survive today on the drained path.
crates/comenqd/src/worker.rsaroundWorkerHooks::notify_drained,WorkerControl::notify_drainedand theclaim_next_due()branch inrun_worker.tokio::select!stops the worker in both the "no due entry" state and the "deferred entry, waiting" state. The negative test depends on a clean worker exit.cargo mutants --list --file crates/comenqd/src/worker.rs. Record the mutants onnotify_drained(body replaced with()) and on theNonebranch.notify_drained_if_emptyandis_metadata_fileno longer exist and that the tests target their current replacements.🤖 Prompt for AI agents
Phase 2: Add direct drained-notification tests
Add one narrow test module with two layers. The first layer checks hook delivery directly. The second layer checks the worker's drain decision for each scenario from the ticket. All assertions poll a
Notifypermit withWaker::noop()after the operation finishes. No assertion uses sleeps or negative timeouts.Task 1: Task 2.1: Create the test module and permit-polling helper
Provide the module skeleton and one shared deterministic assertion.
crates/comenqd/src/worker/tests/scheduling/drained.rs. Start it with//!documentation that explains that the module guards the drained hook against silent mutants.mod drained;incrates/comenqd/src/worker/tests/scheduling.rsnext to the existingmod mutations;. Placement underschedulinggives access toworker_with_successful_posts,pending_entries, the request builders, the clock helpers and the hook-wait helper.notify.notified()future, polls it once withWaker::noop(), and returns whether a permit was ready. Follow the polling pattern inworker/tests.rs:299–321.Ready, and a second fresh poll isPending". This proves that exactly one permit was buffered..expect()with clear messages instead of.unwrap(). Keep the file under 400 lines.Task 2: Task 2.2: Test hook delivery directly
Kill mutants that empty
WorkerHooks::notify_drainedorWorkerControl::notify_drained.WorkerHooks { drained: Some(notify.clone()), ..Default::default() }, wrap it with the privateWorkerControl::new(shutdown, hooks), and callcontrol.notify_drained().drained: None(viaWorkerControl::without_hooks). Assert that the call returns without panic and that an unrelatedNotifyholds no permit.rstest#[case]parameters for theSomeandNonecases, asAGENTS.mdrequires for repeated conditions.Task 3: Task 2.3: Test the worker drain decision across queue states
Map the ticket's three scenarios to the current
claim_next_due()boundary.#[tokio::test(start_paused = true)]and the injected clock, followingworker/tests/scheduling.rs.rstest#[case]values. Give each case its setup, the hook to await, and the expected drained result:temp_configqueue. Awaitdrainedthrough the existing hook-wait helper. Expect notified.dispatch_request(Request::Put(...)). Write a stray.tmpfile into theentries/directory. Runworker_with_successful_posts. Awaitdrained. Assert thatpending_entriesis empty and that root-level metadata (for examplehistory.jsonlorlast_post) exists. Expect notified. This case kills a mutant that fires drained only when the queue directory is empty.waitinghook instead ofdrained. Expect not notified.drainednotify. Inspect the permit only after the worker task has finished. The negative result is then final.pending_entriesstill holds the entry. This proves that the queue was not empty at the observation point.tokio::time::timeoutfor negative assertions. Use the real-thread watchdog only through the existing hook-wait helper.🤖 Prompt for AI agents
Phase 3: Validate
Run the targeted tests, the mutation check and the repository gates.
Task 1: Task 3.1: Run targeted tests and mutation testing
Prove that the new tests pass and kill the drained-path mutants.
cargo test -p comenqd --lib worker::tests::scheduling::drained. Repeat with--all-features.cargo mutants --workspace --all-features --test-workspace=true --file crates/comenqd/src/worker.rs.notify_drainedmutants recorded in Task 1.1 are now caught. Record any mutants that still survive in the pull request description. Do not widen scope to fix them.Task 2: Task 3.2: Run repository gates
Satisfy the
AGENTS.mdpre-commit requirements.make fmt,make check-fmt,make lintandmake test.🤖 Prompt for AI agents
Research
The ticket names
WorkerHooks::notify_drained_if_emptyandis_metadata_file. Neither symbol exists in the current checkout. Retarget the tests at the current drain decision inrun_worker(claim_next_due()returnsNone) and at thenotify_drainedhook path. Do not change production behaviour.crates/comenqd/src/worker.rsdefinesWorkerHooks(cfg-gated totestortest-support) withenqueued,idle,drainedandwaitingfields of typeOption<Arc<Notify>>. The privateWorkerHooks::notify_drainedcallsnotify_one()when thedrainedhook is set.WorkerControl::notify_drainedforwards to it in test builds and is a no-op otherwise.run_workercallscontrol.notify_drained()whenqueue.claim_next_due()returnsNone, then waits on shutdown orqueue.changed().The store separates queue entries from metadata structurally. It scans only
entries/*.json. Root-levellast_post,last_attempt,history.jsonlandcompletion, plus.tmpsiblings, are never pending entries.Worker unit tests live under
crates/comenqd/src/worker/tests/. Child modules can access private ancestor items.worker/tests/scheduling.rsholds helpers such asworker_with_successful_posts,pending_entries, request builders, an injected clock, and a paused-time hook-wait helper with a watchdog.No current test awaits the
drainedhook. Existing scheduling tests create adrainedhook but never wait on it. A mutant that emptiesnotify_drainedtherefore survives.🚀 Next Steps
🤖 All AI agent prompts combined
💡 Iterate on the plan with:
@coderabbitai <feedback>Start Coding →