Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions components/butterfly/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@ pub struct Server {
gossip_rounds: Arc<AtomicIsize>,
block_list: Arc<Lock<HashSet<String>>>,
election_timers: Arc<Mutex<HashMap<String, ElectionTimer>>>,
/// Flag to indicate that new initial members have been added and should be pinged
new_initial_members: Arc<AtomicBool>,
}

impl Clone for Server {
Expand Down Expand Up @@ -338,7 +340,8 @@ impl Clone for Server {
gossip_rounds: self.gossip_rounds.clone(),
block_list: self.block_list.clone(),
socket: None,
election_timers: self.election_timers.clone(), }
election_timers: self.election_timers.clone(),
new_initial_members: self.new_initial_members.clone(), }
}
}

Expand Down Expand Up @@ -401,7 +404,8 @@ impl Server {
gossip_rounds: Arc::new(AtomicIsize::new(0)),
block_list: Arc::new(Lock::new(HashSet::new())),
socket: None,
election_timers: Arc::new(Mutex::new(HashMap::new())) })
election_timers: Arc::new(Mutex::new(HashMap::new())),
new_initial_members: Arc::new(AtomicBool::new(false)) })
}
(Err(e), _) | (_, Err(e)) => Err(Error::CannotBind(e)),
(Ok(None), _) | (_, Ok(None)) => {
Expand Down Expand Up @@ -545,11 +549,6 @@ impl Server {
Ok(())
}

/// # Locking (see locking.md)
/// * `MemberList::entries` (read) Additionally `with_closure` is called with this lock held, so
/// the closure must not call any functions which take this lock.
pub fn need_peer_seeding_mlr(&self) -> bool { self.member_list.is_empty_mlr() }

/// Persistently block a given address, causing no traffic to be seen.
///
/// # Locking (see locking.md)
Expand Down Expand Up @@ -1359,6 +1358,13 @@ impl Server {
}
}

/// Signal that new initial members have been added and should be pinged on the next outbound
/// cycle
pub fn signal_new_initial_members(&self) {
self.new_initial_members.store(true, Ordering::Release);

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing import for Ordering. The atomic::Ordering type is used here but not imported in the visible portion of this file. Ensure use std::sync::atomic::Ordering; is added to the imports at the top of the file.

Copilot uses AI. Check for mistakes.
debug!("Signaled outbound thread that new initial members were added");
}

#[allow(dead_code)]
pub fn is_departed(&self) -> bool { self.departed.load(Ordering::Relaxed) }
}
Expand Down
51 changes: 44 additions & 7 deletions components/butterfly/src/server/outbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ use std::{collections::HashSet,
iter::FromIterator,
net::{SocketAddr,
UdpSocket},
sync::mpsc,
sync::{atomic::Ordering,
mpsc},
thread,
time::{Duration,
Instant}};
Expand Down Expand Up @@ -94,27 +95,63 @@ pub fn spawn_thread(name: String,
/// before starting the next probe.
fn run_loop(server: &Server, socket: &UdpSocket, rx_inbound: &AckReceiver, timing: &Timing) -> ! {
let mut have_members = false;

loop {
liveliness_checker::mark_thread_alive().and_divergent();

if !have_members {
// Check if new initial members have been added via peer watch file
let new_initial_members_added = server.new_initial_members.load(Ordering::Acquire);
if new_initial_members_added {
// Reset the flag and force re-evaluation of initial member pinging
server.new_initial_members.store(false, Ordering::Relaxed);

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flag is loaded with Ordering::Acquire but stored with Ordering::Relaxed. For proper synchronization, this should use Ordering::Release to ensure the store is visible to other threads that may be loading it.

Suggested change
server.new_initial_members.store(false, Ordering::Relaxed);
server.new_initial_members.store(false, Ordering::Release);

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +106

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resetting new_initial_members with a separate load() then store(false, ...) can lose signals: if another thread sets the flag to true between the load and the store, this store will overwrite it back to false, and the outbound loop may miss the update. Use an atomic read-modify-write (e.g., swap(false, Ordering::AcqRel) or compare_exchange loop) to clear the flag without dropping concurrent updates, and use a non-Relaxed ordering consistent with the producer’s Release store.

Suggested change
let new_initial_members_added = server.new_initial_members.load(Ordering::Acquire);
if new_initial_members_added {
// Reset the flag and force re-evaluation of initial member pinging
server.new_initial_members.store(false, Ordering::Relaxed);
let new_initial_members_added =
server.new_initial_members.swap(false, Ordering::AcqRel);
if new_initial_members_added {
// Reset the flag and force re-evaluation of initial member pinging

Copilot uses AI. Check for mistakes.
debug!("New initial members detected, re-evaluating initial member ping logic");
// We don't set have_members = false here because we want to respect the existing
// min_to_start threshold logic, but we will allow the initial member ping to run again
}

if !have_members || new_initial_members_added {
Comment thread
mwrock marked this conversation as resolved.
let num_initial = server.member_list.len_initial_members_imlr();
if num_initial != 0 {
// The minimum that's strictly more than half
#[allow(clippy::integer_division)]
let min_to_start = num_initial / 2 + 1;

if server.member_list.len_mlr() >= min_to_start {
have_members = true;
} else {
server.member_list.with_initial_members_imlr(|member| {
// Only ping initial members that are not already marked as alive
server.member_list.with_initial_members_imlr(|member| {
// Check if this member is already alive in the member list
let member_health = server.member_list.health_of_mlr(member);
let should_ping = match member_health {
Some(Health::Alive) => {
trace!("Skipping ping to initial member {} - \
already marked as Alive",
member.id);
false
}
_ => {
trace!("Pinging initial member {} - health status: \
{:?}",
member.id, member_health);
Comment thread
mwrock marked this conversation as resolved.
true
}
};

if should_ping {
ping_mlr_smr_rhw(server,
socket,
member,
member.swim_socket_address(),
None,
false);
});
}
});

// Only set have_members = true if we're not already in steady state
// and we've reached the threshold
if !have_members && server.member_list.len_mlr() >= min_to_start {
debug!("Reached min_to_start threshold ({} members), stopping initial member \
pings",
min_to_start);
have_members = true;
}
}
}
Expand Down
5 changes: 2 additions & 3 deletions components/sup/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2035,15 +2035,14 @@ impl Manager {
/// * `MemberList::entries` (read)
/// * `MemberList::initial_members` (write)
fn update_peers_from_watch_file_mlr_imlw(&mut self) -> Result<()> {
if !self.butterfly.need_peer_seeding_mlr() {
return Ok(());
}
match self.peer_watcher {
None => Ok(()),
Some(ref watcher) => {
if watcher.has_fs_events() {
let members = watcher.get_members()?;
self.butterfly.member_list.set_initial_members_imlw(members);
// Signal the outbound thread to re-evaluate initial member pinging
self.butterfly.signal_new_initial_members();
}
Ok(())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@ services:
- run
- --listen-ctl=0.0.0.0:9632
- --peer-watch-file=/hab/PEERS

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The volume mount for the PEERS file has been removed, but the supervisor is still configured to watch /hab/PEERS via --peer-watch-file. Without the volume mount, the file won't exist or be accessible for dynamic updates during the test. This will cause the peer watcher test to fail.

Suggested change
- --peer-watch-file=/hab/PEERS
- --peer-watch-file=/hab/PEERS
volumes:
- ./testcases/peer_watcher/PEERS:/hab/PEERS

Copilot uses AI. Check for mistakes.
volumes:
- ./testcases/peer_watcher/PEERS:/hab/PEERS


tester:
Comment on lines 16 to 20

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The peer watch file path is configured on the bastion supervisor (--peer-watch-file=/hab/PEERS), but the bind mount for ./testcases/peer_watcher/PEERS is now only on the tester service. This means edits made by the test may not be visible inside the bastion container, so the supervisor won’t detect changes. Fix by mounting the same host file into bastion as well (or use a shared named volume mounted into both containers at /hab/PEERS).

Copilot uses AI. Check for mistakes.
image: ${TESTCASE}_image
Expand All @@ -30,6 +27,9 @@ services:
COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME}
HAB_AUTH_TOKEN: ${HAB_AUTH_TOKEN}
volumes:
- source: ./testcases/peer_watcher/PEERS
target: /hab/PEERS
type: bind
Comment on lines 29 to +32

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The peer watch file path is configured on the bastion supervisor (--peer-watch-file=/hab/PEERS), but the bind mount for ./testcases/peer_watcher/PEERS is now only on the tester service. This means edits made by the test may not be visible inside the bastion container, so the supervisor won’t detect changes. Fix by mounting the same host file into bastion as well (or use a shared named volume mounted into both containers at /hab/PEERS).

Copilot uses AI. Check for mistakes.
- source: ./CTL_SECRET
target: /hab/sup/default/CTL_SECRET
type: bind
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ Describe "Finding peers from watch file" {
$timeoutScript = { Write-Error "Timed out waiting 45 seconds for all members to reach bastion" }
Wait-True -TestScript $testScript -TimeoutScript $timeoutScript -Timeout 45
$json = (Invoke-WebRequest "http://bastion.habitat.dev:9631/census" | ConvertFrom-Json)
$json.last_membership_counter | Should -Be 2
}

It "adds beta to the peer watch file and finds it as a peer" {
Add-Content -Path "/hab/PEERS" -Value "beta.habitat.dev"
Start-Sleep -Seconds 5 # give butterfly some time to detect the change and update the census

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fixed Start-Sleep makes the e2e test timing-dependent and more prone to flakes under load/CI variance. Prefer polling with the existing Wait-True helper until the census reflects the expected last_membership_counter (with a reasonable timeout and error message), instead of sleeping a fixed duration.

Suggested change
Start-Sleep -Seconds 5 # give butterfly some time to detect the change and update the census
$testScript = { (Invoke-WebRequest "http://bastion.habitat.dev:9631/census" | ConvertFrom-Json).last_membership_counter -eq 3 }
$timeoutScript = { Write-Error "Timed out waiting 45 seconds for beta to be detected and the census to reach last_membership_counter 3" }
Wait-True -TestScript $testScript -TimeoutScript $timeoutScript -Timeout 45

Copilot uses AI. Check for mistakes.
$json = (Invoke-WebRequest "http://bastion.habitat.dev:9631/census" | ConvertFrom-Json)
$json.last_membership_counter | Should -Be 3
}
}
}
Loading