use std::{
collections::HashSet,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
time::Duration,
};
use tracing::{info, warn};
use crate::config::{Config, ServiceConfig};
pub fn service_port(service: &ServiceConfig) -> Option<u16> {
if let Some(port) = service
.deployment
.as_ref()
.and_then(|deployment| deployment.health_check.as_ref())
.and_then(|health| health.url.as_deref())
.and_then(port_from_url)
{
return Some(port);
}
port_from_env(service)
}
fn port_from_url(url: &str) -> Option<u16> {
let without_scheme = url.split("://").nth(1).unwrap_or(url);
let authority = without_scheme
.split(['/', '?', '#'])
.next()
.unwrap_or(without_scheme);
let host_port = authority.rsplit('@').next().unwrap_or(authority);
host_port.rsplit(':').next().and_then(|p| p.parse().ok())
}
fn port_from_env(service: &ServiceConfig) -> Option<u16> {
service
.env
.as_ref()
.and_then(|env| env.vars.as_ref())
.and_then(|vars| vars.get("PORT"))
.and_then(|value| value.parse().ok())
}
#[cfg(target_os = "linux")]
pub fn port_holder(port: u16) -> Option<u32> {
let inodes = listening_inodes(port);
if inodes.is_empty() {
return None;
}
pid_owning_inode(&inodes)
}
#[cfg(not(target_os = "linux"))]
pub fn port_holder(_port: u16) -> Option<u32> {
None
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const TCP_LISTEN: &str = "0A";
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn listening_inode_on_line(line: &str, port: u16) -> Option<u64> {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 10 || fields[3] != TCP_LISTEN {
return None;
}
let local_port = fields[1]
.rsplit(':')
.next()
.and_then(|hex| u16::from_str_radix(hex, 16).ok())?;
if local_port != port {
return None;
}
fields[9].parse::<u64>().ok()
}
#[cfg(target_os = "linux")]
fn listening_inodes(port: u16) -> HashSet<u64> {
let mut inodes = HashSet::new();
for path in ["/proc/net/tcp", "/proc/net/tcp6"] {
let Ok(contents) = std::fs::read_to_string(path) else {
continue;
};
for line in contents.lines().skip(1) {
if let Some(inode) = listening_inode_on_line(line, port) {
inodes.insert(inode);
}
}
}
inodes
}
#[cfg(target_os = "linux")]
fn pid_owning_inode(inodes: &HashSet<u64>) -> Option<u32> {
let entries = std::fs::read_dir("/proc").ok()?;
for entry in entries.filter_map(Result::ok) {
let Some(pid) = entry
.file_name()
.to_str()
.and_then(|name| name.parse::<u32>().ok())
else {
continue;
};
let Ok(fds) = std::fs::read_dir(entry.path().join("fd")) else {
continue;
};
for fd in fds.filter_map(Result::ok) {
let Ok(target) = std::fs::read_link(fd.path()) else {
continue;
};
let target = target.to_string_lossy();
if let Some(inode) = target
.strip_prefix("socket:[")
.and_then(|rest| rest.strip_suffix(']'))
.and_then(|raw| raw.parse::<u64>().ok())
&& inodes.contains(&inode)
{
return Some(pid);
}
}
}
None
}
#[derive(Debug, Clone)]
pub struct Ghost {
pub service: String,
pub port: u16,
pub pid: u32,
}
#[derive(Default)]
pub struct Tracked {
pub pids: HashSet<u32>,
pub pgids: HashSet<i32>,
}
impl Tracked {
fn owns(&self, pid: u32) -> bool {
if self.pids.contains(&pid) {
return true;
}
pgid_of(pid).is_some_and(|pgid| self.pgids.contains(&pgid))
}
}
fn pgid_of(pid: u32) -> Option<i32> {
let pgid = unsafe { libc::getpgid(pid as libc::pid_t) };
if pgid >= 0 { Some(pgid) } else { None }
}
pub fn find_port_ghosts<'a, I>(services: I, tracked: &Tracked) -> Vec<Ghost>
where
I: IntoIterator<Item = (&'a str, &'a ServiceConfig)>,
{
let mut ghosts = Vec::new();
for (name, service) in services {
let Some(port) = service_port(service) else {
continue;
};
let Some(holder) = port_holder(port) else {
continue;
};
if tracked.owns(holder) {
continue;
}
warn!(
"Port {port} for service '{name}' is held by untracked PID {holder}; will reclaim"
);
ghosts.push(Ghost {
service: name.to_string(),
port,
pid: holder,
});
}
ghosts
}
pub fn log_reclaimed(ghost: &Ghost) {
info!(
"Reclaimed port {} for service '{}' by terminating ghost PID {}",
ghost.port, ghost.service, ghost.pid
);
}
pub struct ManagedSnapshot {
pub configs: Vec<Arc<Config>>,
pub tracked: Tracked,
}
pub struct Reconciler {
stop: Arc<AtomicBool>,
handle: Option<thread::JoinHandle<()>>,
}
impl Reconciler {
pub fn spawn<S, R>(interval: Duration, snapshot: S, reclaim: R) -> Self
where
S: Fn() -> ManagedSnapshot + Send + 'static,
R: Fn(&Ghost) + Send + 'static,
{
let stop = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop);
let handle = thread::spawn(move || {
let mut pending: HashSet<(String, u32)> = HashSet::new();
while !stop_clone.load(Ordering::SeqCst) {
let managed = snapshot();
let services = managed
.configs
.iter()
.flat_map(|config| config.services.iter())
.filter(|(_, service)| service.cron.is_none())
.map(|(name, service)| (name.as_str(), service));
let mut seen = HashSet::new();
for ghost in find_port_ghosts(services, &managed.tracked) {
let key = (ghost.service.clone(), ghost.pid);
seen.insert(key.clone());
if pending.contains(&key) {
reclaim(&ghost);
}
}
pending = seen;
let mut slept = Duration::ZERO;
while slept < interval {
if stop_clone.load(Ordering::SeqCst) {
return;
}
let step = Duration::from_millis(200).min(interval - slept);
thread::sleep(step);
slept += step;
}
}
});
Self {
stop,
handle: Some(handle),
}
}
pub fn stop(mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_port_from_health_url() {
assert_eq!(port_from_url("http://127.0.0.1:8100/health"), Some(8100));
assert_eq!(port_from_url("https://api.example.com:443/x"), Some(443));
assert_eq!(port_from_url("http://localhost/health"), None);
}
#[test]
fn extracts_listen_inode_for_matching_port() {
let line = " 3: 0100007F:1F9E 00000000:0000 0A 00000000:00000000 \
00:00000000 00000000 1000 0 54321 1 ffff 100";
assert_eq!(listening_inode_on_line(line, 8094), Some(54321));
assert_eq!(listening_inode_on_line(line, 9000), None);
}
#[test]
fn tracked_owns_pid_directly() {
let tracked = Tracked {
pids: HashSet::from([4242]),
pgids: HashSet::new(),
};
assert!(tracked.owns(4242));
assert!(!tracked.owns(4243));
}
#[test]
fn tracked_owns_by_process_group() {
let own_pgid = pgid_of(std::process::id()).expect("own pgid");
let tracked = Tracked {
pids: HashSet::new(),
pgids: HashSet::from([own_pgid]),
};
assert!(tracked.owns(std::process::id()));
}
#[test]
fn ignores_non_listen_rows() {
let line = " 3: 0100007F:1F9E 0100007F:ABCD 01 00000000:00000000 \
00:00000000 00000000 1000 0 54321 1 ffff 100";
assert_eq!(listening_inode_on_line(line, 8094), None);
}
}