From 4e48d913fd4983abd2d36722ea079dd2811fb661 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Tue, 28 Jul 2026 15:08:37 +0200 Subject: [PATCH] test(config): Fix flaky notify watcher tests ConfigManager.spawn_watch_loop() uses a biased select! with sync_rx.recv() before shutdown.cancelled(). When parallel config tests create temp files in sibling parent directories, inotify fires cross-directory modification events that queue into sync_rx. Each event triggers a 500ms debounce sleep, and the biased select! delays shutdown or renewal indefinitely. Change both tests to #[serial] to isolate their notify watchers from other config tests. Add sleep(100ms) after ConfigManager.watched() so the spawned task has multiple scheduling chances to enter its select! and arm the vault_tick deadline. Wrap shutdown() in a timeout to fail fast if the task still hangs. Signed-off-by: Artem Goncharov --- crates/config/src/lib.rs | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index a8c5e66f8..cdaf9c439 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1031,7 +1031,7 @@ mod tests { } #[tokio::test] - #[parallel] + #[serial] async fn test_shutdown_without_vault_is_noop() { let mut config_file = NamedTempFile::with_suffix(".conf").unwrap(); writeln!( @@ -1041,8 +1041,14 @@ mod tests { .unwrap(); let manager = ConfigManager::watched(config_file.path()).await.unwrap(); - // Must return promptly and not panic for a non-Vault configuration. - manager.shutdown().await; + // #[serial] prevents other config tests' notify watchers from firing + // spurious parent-directory events that queue into sync_rx, triggering + // repeated 500ms debounce sleeps. The biased select! prioritizes + // sync_rx.recv() over shutdown.cancelled(), so a flood of events can + // delay the shutdown break indefinitely. + timeout(Duration::from_secs(10), manager.shutdown()) + .await + .expect("shutdown should not hang for a non-Vault configuration"); } #[tokio::test] @@ -1104,7 +1110,7 @@ mod tests { } #[tokio::test] - #[parallel] + #[serial] async fn test_renewable_vault_token_is_renewed_halfway_through_ttl() { let server = MockServer::start(); let _lookup = mock_lookup(&server, true, 2); @@ -1115,15 +1121,23 @@ mod tests { write_vault_config(&mut config_file, &server, 60); let _manager = ConfigManager::watched(config_file.path()).await.unwrap(); - // Yield so the spawned watch-loop task can enter its first select! - // iteration and arm the vault_tick deadline based on the lookup TTL - // that was set during vault resolution in ConfigManager::watched(). - // Without this yield, the test's timeout clock starts before the - // vault_tick is armed, making the effective wait window shorter and - // susceptible to scheduler jitter on loaded CI runners. - tokio::task::yield_now().await; - - timeout(Duration::from_secs(5), async { + // The renewal deadline (half_ttl of the 2s lookup TTL = 1s) is set as an + // absolute Instant during vault::resolve(), before the spawned watch-loop + // task has entered its select!. We need the task to: + // 1. Start and reach its select! loop + // 2. Have sleep_until() fire when the 1s deadline passes + // 3. Get picked by select! (biased — sync_rx.recv() wins if a notify event is + // queued from spawn-time filesystem activity) + // + // yield_now() is insufficient on loaded CI runners because it only gives + // one scheduling opportunity. A short sleep gives the executor repeated + // chances to run the spawned task. + // #[serial] prevents other config tests' notify watchers from firing + // spurious directory events that fill sync_rx and starve vault_tick via + // select! bias. + sleep(Duration::from_millis(100)).await; + + timeout(Duration::from_secs(10), async { while renewal.calls() == 0 { sleep(Duration::from_millis(50)).await; }