1#![allow(dead_code)]
2use std::{
3 collections::HashSet, os::unix::fs::PermissionsExt, path::Path, sync::Arc, time::Duration,
4};
5
6use tokio::{
7 io::{AsyncBufReadExt, BufReader, split},
8 net::UnixListener,
9 sync::{Mutex, Semaphore},
10 time::{interval, timeout},
11};
12
13use crate::{
14 core::{
15 id::format_commit,
16 state::AppState,
17 watcher::{WatchContext, watch_once},
18 },
19 daemon::server::{DaemonRequest, handle_request},
20 exec::pipeline::run_pipeline,
21 git::repo::Repo,
22};
23
24fn git_poll_timeout() -> Duration {
27 let default_ms = 30_000u64;
28 let ms = std::env::var("FLEET_GIT_POLL_TIMEOUT_MS")
29 .ok()
30 .and_then(|v| v.parse::<u64>().ok())
31 .unwrap_or(default_ms);
32 Duration::from_millis(ms)
33}
34
35fn max_concurrent_git_polls() -> usize {
39 std::env::var("FLEET_MAX_GIT_POLLS")
40 .ok()
41 .and_then(|v| v.parse().ok())
42 .unwrap_or(2)
43}
44
45#[doc = include_str!("docs/supervisor_loop.md")]
46pub async fn supervisor_loop(state: Arc<AppState>, interval_secs: u64) {
47 let mut ticker = interval(Duration::from_secs(interval_secs));
48 let git_semaphore = Arc::new(Semaphore::new(max_concurrent_git_polls()));
49 let git_timeout = git_poll_timeout();
50 let running = Arc::new(Mutex::new(HashSet::new()));
51 let save_lock = Arc::new(Mutex::new(()));
52
53 loop {
54 ticker.tick().await;
55
56 for id in collect_updates(&state, &git_semaphore, git_timeout).await {
57 if running.lock().await.contains(&id) {
58 continue;
60 }
61 let Some(ctx) = get_watch_ctx(&state, &id).await else {
62 continue;
63 };
64 running.lock().await.insert(id.clone());
65
66 let running = Arc::clone(&running);
67 let save_lock = Arc::clone(&save_lock);
68 let state = Arc::clone(&state);
69 let branch = ctx.repo.branches.last_name.clone();
70 let id2 = id.clone();
71
72 tokio::spawn(async move {
73 let ctx = Arc::new(ctx);
74
75 if !branch.is_empty() {
80 let switch_ctx = Arc::clone(&ctx);
81 match timeout(
82 git_timeout,
83 tokio::task::spawn_blocking(move || {
84 Repo::switch_branch(&switch_ctx, &branch)
85 }),
86 )
87 .await
88 {
89 Ok(Ok(Ok(()))) => {}
90 Ok(Ok(Err(e))) => {
91 tracing::warn!(
92 target: "fleet",
93 log_path = %crate::log::logger::Logger::path_by_id(&id2).display(),
94 message = %format!("branch switch failed (ignored): {e}"),
95 );
96 }
97 Ok(Err(e)) => {
98 tracing::warn!(
99 target: "fleet",
100 log_path = %crate::log::logger::Logger::path_by_id(&id2).display(),
101 message = %format!("branch switch task died (ignored): {e}"),
102 );
103 }
104 Err(_) => {
105 tracing::warn!(
106 target: "fleet",
107 log_path = %crate::log::logger::Logger::path_by_id(&id2).display(),
108 message = "branch switch timed out (ignored)",
109 );
110 }
111 }
112 }
113
114 match run_pipeline(ctx).await {
115 Ok(_) => {
116 tracing::info!(
117 target: "fleet",
118 log_path = %crate::log::logger::Logger::path_by_id(&id2).display(),
119 message = "Update succeeded",
120 );
121 let _save = save_lock.lock().await;
122 if let Err(e) = state.save_to_disk().await {
123 tracing::error!(
124 target: "fleet",
125 message = %format!("Failed to save state: {e}"),
126 );
127 }
128 }
129 Err(e) => {
130 tracing::error!(
131 target: "fleet",
132 log_path = %crate::log::logger::Logger::path_by_id(&id2).display(),
133 message = %format!("Update failed => {e}"),
134 );
135 }
136 }
137 running.lock().await.remove(&id2);
138 });
139 }
140 }
141}
142
143async fn collect_updates(
150 state: &Arc<AppState>,
151 git_semaphore: &Arc<Semaphore>,
152 git_timeout: Duration,
153) -> Vec<String> {
154 let snapshot: Vec<(String, bool, crate::git::repo::Repo)> = {
155 let guard = state.watches.read().await;
156 guard
157 .iter()
158 .map(|(id, ctx)| (id.clone(), ctx.paused, ctx.repo.clone()))
159 .collect()
160 };
161
162 let mut updated = Vec::new();
163
164 for (id, paused, mut repo) in snapshot {
165 if paused {
166 continue;
167 }
168
169 let _permit = match git_semaphore.clone().acquire_owned().await {
170 Ok(permit) => permit,
171 Err(_) => break,
172 };
173
174 let poll_result = timeout(
175 git_timeout,
176 tokio::task::spawn_blocking(move || {
177 let result = watch_once(&mut repo);
178 result.map(|detected| {
179 detected.map(|commit| (commit, repo.branches.last_name.clone()))
180 })
181 }),
182 )
183 .await;
184
185 let (new_commit, branch) = match poll_result {
186 Ok(Ok(Ok(Some(found)))) => found,
187 Ok(Ok(Ok(None))) => continue,
188 Ok(Ok(Err(e))) => {
189 tracing::error!(
190 target: "fleet",
191 log_path = %crate::log::logger::Logger::path_by_id(&id).display(),
192 message = %format!("Watch failed: {e}"),
193 );
194 continue;
195 }
196 Ok(Err(e)) => {
197 tracing::error!(
198 target: "fleet",
199 log_path = %crate::log::logger::Logger::path_by_id(&id).display(),
200 message = %format!("Watch task panicked: {e}"),
201 );
202 continue;
203 }
204 Err(_) => {
205 tracing::warn!(
206 target: "fleet",
207 log_path = %crate::log::logger::Logger::path_by_id(&id).display(),
208 message = %format!("git poll timed out after {git_timeout:?}"),
209 );
210 continue;
211 }
212 };
213
214 let found = {
217 let mut guard = state.watches.write().await;
218 match guard.get_mut(&id) {
219 Some(ctx) => {
220 ctx.repo.branches.last_commit = new_commit.clone();
221 ctx.repo.branches.last_name = branch.clone();
222 true
223 }
224 None => false,
225 }
226 };
227 if !found {
228 continue;
229 }
230
231 tracing::info!(
232 target: "fleet",
233 log_path = %crate::log::logger::Logger::path_by_id(&id).display(),
234 message = %format!(
235 "New commit [{}] from branch {}",
236 format_commit(&new_commit),
237 branch
238 ),
239 );
240
241 updated.push(id);
242 }
243
244 updated
245}
246
247pub async fn get_watch_ctx(state: &Arc<AppState>, id: &str) -> Option<WatchContext> {
249 let watches_read: tokio::sync::RwLockReadGuard<
250 '_,
251 std::collections::HashMap<String, WatchContext>,
252 > = state.watches.read().await;
253 watches_read.get(id).cloned()
254}
255
256#[doc = include_str!("docs/start_socket_listener.md")]
257pub async fn start_socket_listener(state: Arc<AppState>) -> anyhow::Result<()> {
258 let sock_path = Path::new("/tmp/fleetd.sock");
259 if sock_path.exists() {
260 std::fs::remove_file(sock_path)?;
261 }
262
263 let listener = UnixListener::bind(sock_path)?;
264 std::fs::set_permissions(sock_path, std::fs::Permissions::from_mode(0o666))?;
265
266 tracing::info!(
267 target: "fleet",
268 message = %format!("fleetd is listening on {sock_path:?}"),
269 );
270
271 loop {
272 let (stream, _) = listener.accept().await?;
273
274 let state = Arc::clone(&state);
275 tokio::spawn(async move {
276 let (read_half, mut write_half) = split(stream);
277 let mut reader = BufReader::new(read_half);
278 let mut buf = String::new();
279
280 let read = timeout(Duration::from_secs(5), reader.read_line(&mut buf)).await;
283 match read {
284 Ok(Ok(_n)) => {}
285 Ok(Err(e)) => {
286 tracing::error!(
287 target: "fleet",
288 message = %format!("Failed to read from stream: {e}"),
289 );
290 return;
291 }
292 Err(_) => {
293 tracing::warn!(
294 target: "fleet",
295 message = "No request received within 5s, closing connection",
296 );
297 return;
298 }
299 }
300
301 let parsed: Result<DaemonRequest, _> = serde_json::from_str(&buf);
302 match parsed {
303 Ok(req) => {
304 if let Err(e) = handle_request(req, state, &mut write_half).await {
305 tracing::error!(
306 target: "fleet",
307 message = %format!("Request handling failed: {e}"),
308 );
309 }
310 }
311 Err(e) => {
312 tracing::error!(
313 target: "fleet",
314 message = %format!("JSON parsing error: {e}"),
315 );
316 }
317 }
318 });
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use std::collections::HashMap;
325 use std::sync::Arc;
326 use std::time::{Duration, Instant};
327
328 use tokio::sync::Semaphore;
329
330 use crate::{
331 config::ProjectConfig,
332 core::state::AppState,
333 core::watcher::WatchContext,
334 git::repo::{Branch, Branches, Repo},
335 log::logger::Logger,
336 };
337
338 use super::collect_updates;
339
340 #[tokio::test]
345 async fn collect_updates_returns_promptly_even_if_remote_hangs() -> anyhow::Result<()> {
346 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
349 let port = listener.local_addr()?.port();
350 let url = format!("ssh://127.0.0.1:{port}/repo.git");
351
352 let (quit_tx, quit_rx) = tokio::sync::oneshot::channel::<()>();
353 let hang_server = tokio::spawn(async move {
354 let mut quit_rx = quit_rx;
355 let mut held = Vec::new();
356 loop {
357 tokio::select! {
358 _ = &mut quit_rx => break,
359 res = listener.accept() => match res {
360 Ok((stream, _)) => held.push(stream),
362 Err(_) => break,
363 },
364 }
365 }
366 drop(held);
367 });
368
369 let ctx = WatchContext {
370 repo: Repo {
371 branches: Branches {
372 branches: vec![Branch {
373 branch: "main".to_string(),
374 last_commit: "old".to_string(),
375 remote: url.clone(),
376 name: "hang-repo".to_string(),
377 }],
378 last_commit: "old".to_string(),
379 last_name: "main".to_string(),
380 name: "main".to_string(),
381 },
382 name: "hang-repo".to_string(),
383 remote: url,
384 },
385 config: ProjectConfig::default(),
386 project_dir: "/nonexistent".to_string(),
387 id: "hang-watch".to_string(),
388 paused: false,
389 logger: Logger::placeholder(),
390 };
391
392 let mut watches = HashMap::new();
393 watches.insert(ctx.id.clone(), ctx);
394 let state = Arc::new(AppState {
395 watches: tokio::sync::RwLock::new(watches),
396 });
397 let sem = Arc::new(Semaphore::new(1));
398
399 let began = Instant::now();
400 let updates = tokio::time::timeout(
401 Duration::from_secs(5),
402 collect_updates(&state, &sem, Duration::from_millis(500)),
403 )
404 .await
405 .map_err(|_| anyhow::anyhow!("collect_updates did not return within 5s"))?;
406
407 assert!(
408 updates.is_empty(),
409 "a hanging remote must not produce updates"
410 );
411 assert!(
412 began.elapsed() < Duration::from_secs(5),
413 "collect_updates must respect the git poll timeout"
414 );
415
416 drop(quit_tx);
418 let _ = hang_server.await;
419 tokio::time::sleep(Duration::from_millis(300)).await;
420
421 Ok(())
422 }
423}