Skip to main content

iso_code/
ports.rs

1//! Port lease allocation.
2//!
3//! Assignment is deterministic: the first four bytes of
4//! `SHA-256(repo_id:branch)` are interpreted as a big-endian `u32`, reduced
5//! modulo the configured range size, and offset by `port_range_start`.
6//! Collisions fall back to sequential probing.
7
8use sha2::{Digest, Sha256};
9
10use crate::error::WorktreeError;
11use crate::types::PortLease;
12
13/// Compute the preferred port for a branch within a port range.
14///
15/// Formula: port_range_start + (sha256(format!("{repo_id}:{branch}"))[0..4] as u32 % range_size)
16pub fn compute_preferred_port(
17    repo_id: &str,
18    branch: &str,
19    port_range_start: u16,
20    port_range_end: u16,
21) -> u16 {
22    let range_size = u32::from(port_range_end - port_range_start);
23    let mut hasher = Sha256::new();
24    hasher.update(format!("{repo_id}:{branch}").as_bytes());
25    let hash = hasher.finalize();
26    // Take first 4 bytes as u32 big-endian
27    let val = u32::from_be_bytes([hash[0], hash[1], hash[2], hash[3]]);
28    port_range_start + (val % range_size) as u16
29}
30
31/// Check if a port lease is expired.
32pub fn is_lease_expired(lease: &PortLease, now: chrono::DateTime<chrono::Utc>) -> bool {
33    lease.expires_at <= now
34}
35
36/// Allocate a port for a branch by probing from preferred port, wrapping around.
37///
38/// Returns the allocated port number.
39pub fn allocate_port(
40    repo_id: &str,
41    branch: &str,
42    _session_uuid: &str,
43    port_range_start: u16,
44    port_range_end: u16,
45    existing_leases: &std::collections::HashMap<String, PortLease>,
46) -> Result<u16, WorktreeError> {
47    let range_size = (port_range_end - port_range_start) as u32;
48    if range_size == 0 {
49        return Err(WorktreeError::RateLimitExceeded {
50            current: 0,
51            max: 0,
52        });
53    }
54
55    let now = chrono::Utc::now();
56    let preferred = compute_preferred_port(repo_id, branch, port_range_start, port_range_end);
57    let preferred_offset = preferred - port_range_start;
58
59    // Collect taken ports (non-expired)
60    let taken: std::collections::HashSet<u16> = existing_leases
61        .values()
62        .filter(|l| !is_lease_expired(l, now))
63        .map(|l| l.port)
64        .collect();
65
66    // Linear probe from preferred, wrapping around
67    for i in 0..range_size {
68        let offset = (u32::from(preferred_offset) + i) % range_size;
69        let port = port_range_start + offset as u16;
70        if !taken.contains(&port) {
71            return Ok(port);
72        }
73    }
74
75    Err(WorktreeError::RateLimitExceeded {
76        current: range_size as usize,
77        max: range_size as usize,
78    })
79}
80
81/// Build a PortLease with 8-hour TTL.
82pub fn make_lease(
83    port: u16,
84    branch: &str,
85    session_uuid: &str,
86    pid: u32,
87) -> PortLease {
88    let now = chrono::Utc::now();
89    PortLease {
90        port,
91        branch: branch.to_string(),
92        session_uuid: session_uuid.to_string(),
93        pid,
94        created_at: now,
95        expires_at: now + chrono::Duration::hours(8),
96        status: "active".to_string(),
97    }
98}
99
100/// Renew a lease — extend expires_at by another 8 hours from now.
101pub fn renew_lease(lease: &mut PortLease) {
102    lease.expires_at = chrono::Utc::now() + chrono::Duration::hours(8);
103}
104
105/// Sweep expired leases from the map, returning how many were removed.
106pub fn sweep_expired_leases(
107    leases: &mut std::collections::HashMap<String, PortLease>,
108    now: chrono::DateTime<chrono::Utc>,
109) -> usize {
110    let expired_keys: Vec<String> = leases
111        .iter()
112        .filter(|(_, l)| is_lease_expired(l, now))
113        .map(|(k, _)| k.clone())
114        .collect();
115    let count = expired_keys.len();
116    for k in expired_keys {
117        leases.remove(&k);
118    }
119    count
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use std::collections::HashMap;
126
127    const REPO_ID: &str = "test-repo-abc123";
128    const START: u16 = 3100;
129    const END: u16 = 5100;
130
131    #[test]
132    fn preferred_port_is_deterministic() {
133        let p1 = compute_preferred_port(REPO_ID, "main", START, END);
134        let p2 = compute_preferred_port(REPO_ID, "main", START, END);
135        assert_eq!(p1, p2);
136        assert!((START..END).contains(&p1));
137    }
138
139    #[test]
140    fn preferred_port_is_in_range() {
141        let port = compute_preferred_port(REPO_ID, "feature/test", START, END);
142        assert!((START..END).contains(&port));
143    }
144
145    #[test]
146    fn allocate_no_collision() {
147        let leases = HashMap::new();
148        let port = allocate_port(REPO_ID, "main", "uuid-1", START, END, &leases).unwrap();
149        assert!((START..END).contains(&port));
150    }
151
152    #[test]
153    fn allocate_probes_on_collision() {
154        let preferred = compute_preferred_port(REPO_ID, "branch-a", START, END);
155
156        // Occupy the preferred port
157        let mut leases = HashMap::new();
158        let lease = make_lease(preferred, "other", "uuid-other", 1234);
159        leases.insert("other".to_string(), lease);
160
161        let port = allocate_port(REPO_ID, "branch-a", "uuid-a", START, END, &leases).unwrap();
162        assert_ne!(port, preferred); // Must have probed to a different port
163        assert!((START..END).contains(&port));
164    }
165
166    #[test]
167    fn allocate_full_range_returns_error() {
168        // Small range: 3100-3102 (2 ports)
169        let start: u16 = 3100;
170        let end: u16 = 3102;
171
172        let mut leases = HashMap::new();
173        // Fill both ports
174        let now = chrono::Utc::now();
175        let expires = now + chrono::Duration::hours(8);
176        for port in start..end {
177            leases.insert(
178                port.to_string(),
179                PortLease {
180                    port,
181                    branch: format!("branch-{port}"),
182                    session_uuid: format!("uuid-{port}"),
183                    pid: 1,
184                    created_at: now,
185                    expires_at: expires,
186                    status: "active".to_string(),
187                },
188            );
189        }
190
191        let result = allocate_port(REPO_ID, "new-branch", "uuid-new", start, end, &leases);
192        assert!(result.is_err());
193    }
194
195    #[test]
196    fn expired_lease_frees_port() {
197        let preferred = compute_preferred_port(REPO_ID, "branch-b", START, END);
198
199        // Expired lease on preferred port
200        let mut leases = HashMap::new();
201        let past = chrono::Utc::now() - chrono::Duration::hours(1);
202        leases.insert(
203            "expired".to_string(),
204            PortLease {
205                port: preferred,
206                branch: "other".to_string(),
207                session_uuid: "uuid-exp".to_string(),
208                pid: 9999,
209                created_at: past - chrono::Duration::hours(8),
210                expires_at: past,
211                status: "active".to_string(),
212            },
213        );
214
215        // Should get the preferred port since the lease is expired
216        let port = allocate_port(REPO_ID, "branch-b", "uuid-b", START, END, &leases).unwrap();
217        assert_eq!(port, preferred);
218    }
219
220    #[test]
221    fn twenty_branches_get_unique_ports() {
222        // Use a small range to force probing
223        let start: u16 = 3100;
224        let end: u16 = 3120; // Only 20 ports
225
226        let mut leases = HashMap::new();
227        let mut allocated = std::collections::HashSet::new();
228
229        for i in 0..20u32 {
230            let branch = format!("branch-{i}");
231            let session = format!("uuid-{i}");
232            let port = allocate_port(REPO_ID, &branch, &session, start, end, &leases).unwrap();
233            assert!(allocated.insert(port), "port {port} was allocated twice");
234            leases.insert(branch.clone(), make_lease(port, &branch, &session, 1234));
235        }
236
237        assert_eq!(allocated.len(), 20);
238    }
239
240    #[test]
241    fn sweep_removes_expired_leases() {
242        let mut leases = HashMap::new();
243        let past = chrono::Utc::now() - chrono::Duration::hours(1);
244        let future = chrono::Utc::now() + chrono::Duration::hours(7);
245
246        leases.insert(
247            "expired".to_string(),
248            PortLease {
249                port: 3100,
250                branch: "old".to_string(),
251                session_uuid: "u1".to_string(),
252                pid: 1,
253                created_at: past - chrono::Duration::hours(8),
254                expires_at: past,
255                status: "active".to_string(),
256            },
257        );
258        leases.insert(
259            "active".to_string(),
260            PortLease {
261                port: 3101,
262                branch: "new".to_string(),
263                session_uuid: "u2".to_string(),
264                pid: 2,
265                created_at: chrono::Utc::now() - chrono::Duration::hours(1),
266                expires_at: future,
267                status: "active".to_string(),
268            },
269        );
270
271        let removed = sweep_expired_leases(&mut leases, chrono::Utc::now());
272        assert_eq!(removed, 1);
273        assert!(!leases.contains_key("expired"));
274        assert!(leases.contains_key("active"));
275    }
276
277    #[test]
278    fn renew_extends_expiry() {
279        let mut lease = make_lease(3100, "main", "uuid-1", 1234);
280        let original_expiry = lease.expires_at;
281        std::thread::sleep(std::time::Duration::from_millis(10));
282        renew_lease(&mut lease);
283        assert!(lease.expires_at > original_expiry);
284    }
285}