use std::collections::HashSet;
use std::net::TcpListener;
use std::sync::Mutex;
use crate::error::{Result, RightsizeError};
const MAX_ALLOCATE_ATTEMPTS: usize = 100;
pub(crate) struct FreePorts {
issued: Mutex<HashSet<u16>>,
}
impl FreePorts {
pub(crate) fn new() -> Self {
Self {
issued: Mutex::new(HashSet::new()),
}
}
pub(crate) fn allocate(&self) -> Result<u16> {
let mut issued = self.issued.lock().expect("FreePorts mutex poisoned");
for _ in 0..MAX_ALLOCATE_ATTEMPTS {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
drop(listener);
if issued.insert(port) {
return Ok(port);
}
}
Err(RightsizeError::Backend(format!(
"Could not allocate a free TCP port after {MAX_ALLOCATE_ATTEMPTS} attempts"
)))
}
pub(crate) fn release(&self, port: u16) {
self.issued
.lock()
.expect("FreePorts mutex poisoned")
.remove(&port);
}
#[cfg(test)]
pub(crate) fn issued_view(&self) -> HashSet<u16> {
self.issued
.lock()
.expect("FreePorts mutex poisoned")
.clone()
}
}
impl Default for FreePorts {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
#[test]
fn allocated_ports_are_unique_and_bindable() {
let pool = FreePorts::new();
let mut ports = Vec::new();
for _ in 0..50 {
ports.push(pool.allocate().expect("allocate"));
}
let unique: HashSet<u16> = ports.iter().copied().collect();
assert_eq!(
unique.len(),
ports.len(),
"ports must not repeat within the process"
);
let last = *ports.last().unwrap();
let listener = TcpListener::bind(("127.0.0.1", last))
.expect("released listener slot must be bindable");
assert_eq!(listener.local_addr().unwrap().port(), last);
}
#[test]
fn release_removes_the_port_from_issued_view_and_it_can_be_reissued() {
let pool = FreePorts::new();
let port = pool.allocate().expect("allocate");
assert!(pool.issued_view().contains(&port));
pool.release(port);
assert!(
!pool.issued_view().contains(&port),
"release must drop the port from issued_view()"
);
let listener =
TcpListener::bind(("127.0.0.1", port)).expect("released port must be bindable again");
assert_eq!(listener.local_addr().unwrap().port(), port);
}
#[test]
fn release_is_not_a_no_op() {
let pool = FreePorts::new();
let port = pool.allocate().expect("allocate");
assert!(
pool.issued_view().contains(&port),
"sanity: allocate() must record the port"
);
pool.release(port);
assert!(
!pool.issued_view().contains(&port),
"release(port) must remove it from issued_view() — if this fails, release() is a no-op"
);
}
#[test]
fn release_of_an_unissued_port_is_a_harmless_no_op() {
let pool = FreePorts::new();
pool.release(65000);
assert!(pool.issued_view().is_empty());
}
}