use sha2::{Digest, Sha256};
use crate::error::WorktreeError;
use crate::types::PortLease;
pub fn compute_preferred_port(
repo_id: &str,
branch: &str,
port_range_start: u16,
port_range_end: u16,
) -> u16 {
let range_size = u32::from(port_range_end - port_range_start);
let mut hasher = Sha256::new();
hasher.update(format!("{repo_id}:{branch}").as_bytes());
let hash = hasher.finalize();
let val = u32::from_be_bytes([hash[0], hash[1], hash[2], hash[3]]);
port_range_start + (val % range_size) as u16
}
pub fn is_lease_expired(lease: &PortLease, now: chrono::DateTime<chrono::Utc>) -> bool {
lease.expires_at <= now
}
pub fn allocate_port(
repo_id: &str,
branch: &str,
_session_uuid: &str,
port_range_start: u16,
port_range_end: u16,
existing_leases: &std::collections::HashMap<String, PortLease>,
) -> Result<u16, WorktreeError> {
let range_size = (port_range_end - port_range_start) as u32;
if range_size == 0 {
return Err(WorktreeError::RateLimitExceeded {
current: 0,
max: 0,
});
}
let now = chrono::Utc::now();
let preferred = compute_preferred_port(repo_id, branch, port_range_start, port_range_end);
let preferred_offset = preferred - port_range_start;
let taken: std::collections::HashSet<u16> = existing_leases
.values()
.filter(|l| !is_lease_expired(l, now))
.map(|l| l.port)
.collect();
for i in 0..range_size {
let offset = (u32::from(preferred_offset) + i) % range_size;
let port = port_range_start + offset as u16;
if !taken.contains(&port) {
return Ok(port);
}
}
Err(WorktreeError::RateLimitExceeded {
current: range_size as usize,
max: range_size as usize,
})
}
pub fn make_lease(
port: u16,
branch: &str,
session_uuid: &str,
pid: u32,
) -> PortLease {
let now = chrono::Utc::now();
PortLease {
port,
branch: branch.to_string(),
session_uuid: session_uuid.to_string(),
pid,
created_at: now,
expires_at: now + chrono::Duration::hours(8),
status: "active".to_string(),
}
}
pub fn renew_lease(lease: &mut PortLease) {
lease.expires_at = chrono::Utc::now() + chrono::Duration::hours(8);
}
pub fn sweep_expired_leases(
leases: &mut std::collections::HashMap<String, PortLease>,
now: chrono::DateTime<chrono::Utc>,
) -> usize {
let expired_keys: Vec<String> = leases
.iter()
.filter(|(_, l)| is_lease_expired(l, now))
.map(|(k, _)| k.clone())
.collect();
let count = expired_keys.len();
for k in expired_keys {
leases.remove(&k);
}
count
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
const REPO_ID: &str = "test-repo-abc123";
const START: u16 = 3100;
const END: u16 = 5100;
#[test]
fn preferred_port_is_deterministic() {
let p1 = compute_preferred_port(REPO_ID, "main", START, END);
let p2 = compute_preferred_port(REPO_ID, "main", START, END);
assert_eq!(p1, p2);
assert!((START..END).contains(&p1));
}
#[test]
fn preferred_port_is_in_range() {
let port = compute_preferred_port(REPO_ID, "feature/test", START, END);
assert!((START..END).contains(&port));
}
#[test]
fn allocate_no_collision() {
let leases = HashMap::new();
let port = allocate_port(REPO_ID, "main", "uuid-1", START, END, &leases).unwrap();
assert!((START..END).contains(&port));
}
#[test]
fn allocate_probes_on_collision() {
let preferred = compute_preferred_port(REPO_ID, "branch-a", START, END);
let mut leases = HashMap::new();
let lease = make_lease(preferred, "other", "uuid-other", 1234);
leases.insert("other".to_string(), lease);
let port = allocate_port(REPO_ID, "branch-a", "uuid-a", START, END, &leases).unwrap();
assert_ne!(port, preferred); assert!((START..END).contains(&port));
}
#[test]
fn allocate_full_range_returns_error() {
let start: u16 = 3100;
let end: u16 = 3102;
let mut leases = HashMap::new();
let now = chrono::Utc::now();
let expires = now + chrono::Duration::hours(8);
for port in start..end {
leases.insert(
port.to_string(),
PortLease {
port,
branch: format!("branch-{port}"),
session_uuid: format!("uuid-{port}"),
pid: 1,
created_at: now,
expires_at: expires,
status: "active".to_string(),
},
);
}
let result = allocate_port(REPO_ID, "new-branch", "uuid-new", start, end, &leases);
assert!(result.is_err());
}
#[test]
fn expired_lease_frees_port() {
let preferred = compute_preferred_port(REPO_ID, "branch-b", START, END);
let mut leases = HashMap::new();
let past = chrono::Utc::now() - chrono::Duration::hours(1);
leases.insert(
"expired".to_string(),
PortLease {
port: preferred,
branch: "other".to_string(),
session_uuid: "uuid-exp".to_string(),
pid: 9999,
created_at: past - chrono::Duration::hours(8),
expires_at: past,
status: "active".to_string(),
},
);
let port = allocate_port(REPO_ID, "branch-b", "uuid-b", START, END, &leases).unwrap();
assert_eq!(port, preferred);
}
#[test]
fn twenty_branches_get_unique_ports() {
let start: u16 = 3100;
let end: u16 = 3120;
let mut leases = HashMap::new();
let mut allocated = std::collections::HashSet::new();
for i in 0..20u32 {
let branch = format!("branch-{i}");
let session = format!("uuid-{i}");
let port = allocate_port(REPO_ID, &branch, &session, start, end, &leases).unwrap();
assert!(allocated.insert(port), "port {port} was allocated twice");
leases.insert(branch.clone(), make_lease(port, &branch, &session, 1234));
}
assert_eq!(allocated.len(), 20);
}
#[test]
fn sweep_removes_expired_leases() {
let mut leases = HashMap::new();
let past = chrono::Utc::now() - chrono::Duration::hours(1);
let future = chrono::Utc::now() + chrono::Duration::hours(7);
leases.insert(
"expired".to_string(),
PortLease {
port: 3100,
branch: "old".to_string(),
session_uuid: "u1".to_string(),
pid: 1,
created_at: past - chrono::Duration::hours(8),
expires_at: past,
status: "active".to_string(),
},
);
leases.insert(
"active".to_string(),
PortLease {
port: 3101,
branch: "new".to_string(),
session_uuid: "u2".to_string(),
pid: 2,
created_at: chrono::Utc::now() - chrono::Duration::hours(1),
expires_at: future,
status: "active".to_string(),
},
);
let removed = sweep_expired_leases(&mut leases, chrono::Utc::now());
assert_eq!(removed, 1);
assert!(!leases.contains_key("expired"));
assert!(leases.contains_key("active"));
}
#[test]
fn renew_extends_expiry() {
let mut lease = make_lease(3100, "main", "uuid-1", 1234);
let original_expiry = lease.expires_at;
std::thread::sleep(std::time::Duration::from_millis(10));
renew_lease(&mut lease);
assert!(lease.expires_at > original_expiry);
}
}