Skip to main content

core_lib/core/
manager.rs

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
24/// How long a single git poll (`watch_once`) may take before it is abandoned.
25/// Tunable via `FLEET_GIT_POLL_TIMEOUT_MS` (default 30s).
26fn 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
35/// Max number of git polls running at once. Tunable via `FLEET_MAX_GIT_POLLS`
36/// (default 2). Blocking git2 calls are kept off the async runtime and bounded
37/// in flight so that one unreachable remote cannot starve the whole daemon.
38fn 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                // A pipeline for this watch is already in flight, skip it.
59                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                // Switch to the branch to be deployed before running the
76                // pipeline. Checkout is blocking git2 I/O, so it runs off the
77                // async runtime and is best-effort, matching the previous
78                // `Repo::switch_branch(...).ok()` semantics.
79                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
143/// Poll every active watch for new commits.
144///
145/// The state lock is only ever held for cheap in-memory reads/writes: the
146/// slow git network calls happen in `spawn_blocking` after a snapshot has been
147/// taken and are bounded by `git_timeout`, so a stalled remote no longer blocks
148/// the socket listener, the `run` command, or any other watch.
149async 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        // Apply the new commit under a short lock; the log write is a
215        // `tracing` event emitted after the lock is dropped.
216        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
247/// Clones the `WatchContext` of a watch, if it still exists.
248pub 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            // A client that connects but never sends a request must not hold
281            // its spawned task (and socket) forever.
282            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    /// Regression test: a remote that accepts connections but never answers
341    /// used to block the daemon for the OS-level connect/read timeout while
342    /// holding the state lock. `collect_updates` must now give up within the
343    /// configured git poll timeout and keep the rest of the daemon responsive.
344    #[tokio::test]
345    async fn collect_updates_returns_promptly_even_if_remote_hangs() -> anyhow::Result<()> {
346        // A TCP server that accepts connections but never speaks. libgit2's ssh
347        // transport blocks forever waiting for the server's handshake banner.
348        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                        // Hold the socket open, never respond.
361                        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        // Release the held sockets so the leaked blocking git2 task unwinds.
417        drop(quit_tx);
418        let _ = hang_server.await;
419        tokio::time::sleep(Duration::from_millis(300)).await;
420
421        Ok(())
422    }
423}