use std::collections::BTreeMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub trait PortAllocator: Send + Sync {
fn resolve(&self, ident: &str, bind_ip: IpAddr, declared: Option<u16>) -> Result<u16>;
fn release(&self, ident: &str);
}
pub fn pick_free_port(bind_ip: IpAddr) -> Result<u16> {
let listener = TcpListener::bind(SocketAddr::new(bind_ip, 0))
.with_context(|| format!("could not bind an ephemeral port on {bind_ip}"))?;
Ok(listener
.local_addr()
.context("ephemeral listener has no local address")?
.port())
}
fn is_free(bind_ip: IpAddr, port: u16) -> bool {
TcpListener::bind(SocketAddr::new(bind_ip, port)).is_ok()
}
#[derive(Debug, Clone, Copy, Default)]
pub struct EphemeralPorts;
impl PortAllocator for EphemeralPorts {
fn resolve(&self, _ident: &str, bind_ip: IpAddr, declared: Option<u16>) -> Result<u16> {
match declared {
Some(p) if p != 0 && is_free(bind_ip, p) => Ok(p),
Some(p) if p != 0 => {
let fallback = pick_free_port(bind_ip)?;
tracing::info!(
preferred = p,
actual = fallback,
"declared port is taken; falling back to an OS-assigned port"
);
Ok(fallback)
}
_ => pick_free_port(bind_ip),
}
}
fn release(&self, _ident: &str) {}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LedgerEntry {
port: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LedgerFile {
version: u32,
ports: BTreeMap<String, LedgerEntry>,
}
const LEDGER_VERSION: u32 = 1;
pub struct LedgerPorts {
path: PathBuf,
reserved: Mutex<BTreeMap<String, u16>>,
}
impl LedgerPorts {
pub const FILE_NAME: &'static str = "ports.json";
pub fn open(state_dir: impl Into<PathBuf>) -> Self {
let path = state_dir.into().join(Self::FILE_NAME);
let reserved = load(&path);
Self {
path,
reserved: Mutex::new(reserved),
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn reserved_port(&self, ident: &str) -> Option<u16> {
self.reserved.lock().ok()?.get(ident).copied()
}
fn save(&self, reserved: &BTreeMap<String, u16>) {
let file = LedgerFile {
version: LEDGER_VERSION,
ports: reserved
.iter()
.map(|(k, &port)| (k.clone(), LedgerEntry { port }))
.collect(),
};
if let Err(e) = write_atomic(&self.path, &file) {
tracing::warn!(
path = %self.path.display(),
error = format!("{e:#}"),
"ports: could not persist the port ledger; allocations hold in \
memory but a restart before the next successful write will \
reallocate"
);
}
}
}
impl PortAllocator for LedgerPorts {
fn resolve(&self, ident: &str, bind_ip: IpAddr, declared: Option<u16>) -> Result<u16> {
if let Some(port) = declared.filter(|&p| p != 0) {
return Ok(port);
}
let mut reserved = self
.reserved
.lock()
.map_err(|_| anyhow::anyhow!("port ledger mutex poisoned"))?;
if let Some(&port) = reserved.get(ident) {
if is_free(bind_ip, port) || reserved.values().filter(|&&p| p == port).count() == 1 {
return Ok(port);
}
}
let mut port = pick_free_port(bind_ip)?;
for _ in 0..16 {
if !reserved.values().any(|&p| p == port) {
break;
}
port = pick_free_port(bind_ip)?;
}
anyhow::ensure!(
!reserved.values().any(|&p| p == port),
"could not find a port on {bind_ip} that the ledger has not already \
reserved for another workload"
);
reserved.insert(ident.to_string(), port);
let snapshot = reserved.clone();
drop(reserved);
self.save(&snapshot);
Ok(port)
}
fn release(&self, ident: &str) {
let Ok(mut reserved) = self.reserved.lock() else {
return;
};
if reserved.remove(ident).is_none() {
return;
}
let snapshot = reserved.clone();
drop(reserved);
self.save(&snapshot);
}
}
fn load(path: &Path) -> BTreeMap<String, u16> {
let Ok(bytes) = std::fs::read(path) else {
return BTreeMap::new();
};
match serde_json::from_slice::<LedgerFile>(&bytes) {
Ok(file) if file.version == LEDGER_VERSION => file
.ports
.into_iter()
.map(|(ident, entry)| (ident, entry.port))
.collect(),
Ok(file) => {
tracing::warn!(
path = %path.display(),
found = file.version,
expected = LEDGER_VERSION,
"ports: unrecognized port-ledger version; starting empty"
);
BTreeMap::new()
}
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"ports: unreadable port ledger; starting empty"
);
BTreeMap::new()
}
}
}
fn write_atomic(path: &Path, file: &LedgerFile) -> Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.with_context(|| format!("create port-ledger dir {}", dir.display()))?;
}
let tmp = path.with_extension("json.tmp");
let bytes = serde_json::to_vec_pretty(file).context("serialize port ledger")?;
std::fs::write(&tmp, bytes).with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| format!("rename into {}", path.display()))?;
Ok(())
}
pub const LOOPBACK: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_declared_port_is_returned_unchanged_by_both_allocators() {
let dir = tempfile::tempdir().unwrap();
let ledger = LedgerPorts::open(dir.path());
assert_eq!(
EphemeralPorts.resolve("a", LOOPBACK, Some(31_337)).unwrap(),
31_337,
"an unused declared port is honoured verbatim"
);
assert_eq!(
ledger.resolve("a", LOOPBACK, Some(31_337)).unwrap(),
31_337
);
assert_eq!(
ledger.reserved_port("a"),
None,
"a declared port is not recorded as a reservation — see resolve()'s \
comment on why writing it would relocate the pin rather than remove it"
);
}
#[test]
fn undeclared_workloads_get_distinct_ports_on_one_node() {
let dir = tempfile::tempdir().unwrap();
let ledger = LedgerPorts::open(dir.path());
let a = ledger.resolve("yah-marketing", LOOPBACK, None).unwrap();
let b = ledger.resolve("noisetable-com", LOOPBACK, None).unwrap();
assert_ne!(a, 0);
assert_ne!(b, 0);
assert_ne!(a, b, "co-tenants must not be handed the same port");
}
#[test]
fn resolving_the_same_ident_twice_is_stable_within_a_process() {
let dir = tempfile::tempdir().unwrap();
let ledger = LedgerPorts::open(dir.path());
let first = ledger.resolve("yah-marketing", LOOPBACK, None).unwrap();
let again = ledger.resolve("yah-marketing", LOOPBACK, None).unwrap();
assert_eq!(first, again);
}
#[test]
fn a_reservation_survives_a_supervisor_restart() {
let dir = tempfile::tempdir().unwrap();
let first = {
let ledger = LedgerPorts::open(dir.path());
ledger.resolve("yah-marketing", LOOPBACK, None).unwrap()
};
let reopened = LedgerPorts::open(dir.path());
assert_eq!(
reopened.reserved_port("yah-marketing"),
Some(first),
"the ledger on disk is what makes the port stable across restart"
);
assert_eq!(
reopened.resolve("yah-marketing", LOOPBACK, None).unwrap(),
first
);
}
#[test]
fn releasing_returns_the_port_to_the_pool_and_persists() {
let dir = tempfile::tempdir().unwrap();
let ledger = LedgerPorts::open(dir.path());
ledger.resolve("gone", LOOPBACK, None).unwrap();
ledger.release("gone");
assert_eq!(ledger.reserved_port("gone"), None);
assert_eq!(
LedgerPorts::open(dir.path()).reserved_port("gone"),
None,
"release must reach disk, or a restart resurrects a dead reservation"
);
ledger.release("gone");
ledger.release("never-existed");
}
#[test]
fn a_corrupt_ledger_starts_empty_instead_of_failing() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(LedgerPorts::FILE_NAME), b"{not json").unwrap();
let ledger = LedgerPorts::open(dir.path());
assert_eq!(ledger.reserved_port("anything"), None);
assert_ne!(ledger.resolve("anything", LOOPBACK, None).unwrap(), 0);
}
#[test]
fn an_unknown_ledger_version_starts_empty() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(LedgerPorts::FILE_NAME),
br#"{"version":9999,"ports":{"yah-marketing":{"port":8080}}}"#,
)
.unwrap();
assert_eq!(
LedgerPorts::open(dir.path()).reserved_port("yah-marketing"),
None
);
}
#[test]
fn ephemeral_allocator_hands_out_a_usable_port_when_nothing_is_declared() {
let port = EphemeralPorts.resolve("dev", LOOPBACK, None).unwrap();
assert_ne!(port, 0);
assert!(is_free(LOOPBACK, port));
}
#[test]
fn a_taken_declared_port_floats_on_the_local_tier() {
let held = TcpListener::bind("127.0.0.1:0").unwrap();
let taken = held.local_addr().unwrap().port();
let got = EphemeralPorts.resolve("dev", LOOPBACK, Some(taken)).unwrap();
assert_ne!(
got, taken,
"the local tier floats off a taken port rather than failing bring-up"
);
}
}