1use sha2::{Digest, Sha256};
9
10use crate::error::WorktreeError;
11use crate::types::PortLease;
12
13pub 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 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
31pub fn is_lease_expired(lease: &PortLease, now: chrono::DateTime<chrono::Utc>) -> bool {
33 lease.expires_at <= now
34}
35
36pub 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 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 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
81pub 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
100pub fn renew_lease(lease: &mut PortLease) {
102 lease.expires_at = chrono::Utc::now() + chrono::Duration::hours(8);
103}
104
105pub 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 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); assert!((START..END).contains(&port));
164 }
165
166 #[test]
167 fn allocate_full_range_returns_error() {
168 let start: u16 = 3100;
170 let end: u16 = 3102;
171
172 let mut leases = HashMap::new();
173 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 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 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 let start: u16 = 3100;
224 let end: u16 = 3120; 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}