Skip to main content

webdav_server/logger/
service.rs

1use std::{path::PathBuf, time::Duration};
2
3use bincode_next::{config, encode_to_vec};
4use chrono::{DateTime, Datelike};
5use tokio::{
6    fs::{self, File, create_dir_all},
7    io::AsyncWriteExt,
8    net::UnixDatagram,
9    spawn,
10    sync::mpsc::{Receiver, Sender, channel},
11    task::JoinHandle,
12    time::timeout,
13};
14
15use crate::{
16    fatal,
17    logger::{
18        Entry, Level, Module,
19        error::{Error::LogDirectoryInitialization, Result},
20    },
21};
22
23/// The filesystem path of the Unix Datagram Socket used for real-time log broadcasting.
24///
25/// The server's background logging task sends a copy of each serialized [`Entry`] to this
26/// address after writing it to disk. The admin CLI (`kosh-cli`) binds to this socket to
27/// receive the live stream. If no client is bound, the `send_to` call fails silently — the
28/// server deliberately ignores the error so that the absence of the CLI never affects
29/// request-path performance.
30pub static SOCKET_ADDR: &str = "/tmp/kosh-cli.sock";
31
32/// The number of milliseconds in one calendar day (24 * 60 * 60 * 1000).
33///
34/// Used to determine which daily log file an entry belongs to by comparing
35/// `entry.timestamp_ms / DAY_MILLIS` against the service's `today` field.
36static DAY_MILLIS: i64 = 86_400_000;
37
38/// The background logging service.
39///
40/// `Service` owns the receive end of the MPSC channel, the currently active log file,
41/// and the unbound Unix Datagram Socket used for broadcasting. It runs entirely on a
42/// dedicated `tokio` task and never shares memory with the HTTP request threads.
43///
44/// Callers interact with the service indirectly through the [`GLOBAL_LOGGER`] sender
45/// and the [`LoggerHandler`] returned by [`Service::start`]. The `Service` itself is
46/// consumed by the background task and is not accessible after startup.
47pub struct Service {
48    /// The receive end of the bounded MPSC channel.
49    ///
50    /// The service loops on this receiver, processing one [`Entry`] at a time.
51    receiver: Receiver<Entry>,
52    /// The currently open log file, opened in append mode.
53    ///
54    /// This handle is replaced atomically (at the Rust level, not the OS level) when the
55    /// service detects that a new calendar day has started, implementing log file rotation.
56    active_file: File,
57    /// The calendar day (as `timestamp_ms / DAY_MILLIS`) of the currently active log file.
58    ///
59    /// Compared against each incoming entry's timestamp to detect when a day boundary
60    /// has been crossed and a new log file must be opened.
61    today: i64,
62    /// The absolute path to the `kosh/logs` directory.
63    ///
64    /// Derived from `dirs::state_dir()` at startup and used when opening new daily files
65    /// during log rotation.
66    log_path: PathBuf,
67    /// An unbound Unix Datagram Socket used to broadcast entries to the admin CLI.
68    ///
69    /// Unbound means the socket has no address of its own; it can only send, not receive.
70    /// Each entry is sent to [`SOCKET_ADDR`] after being written to disk. Errors are
71    /// silently ignored so that the absence of the admin CLI has no impact on the server.
72    socket: UnixDatagram,
73}
74
75impl Service {
76    /// Initializes the logging service and spawns its background task.
77    ///
78    /// This method must be called once during server startup. It:
79    ///
80    /// 1. Creates a bounded MPSC channel with the specified `capacity`.
81    /// 2. Resolves the XDG state directory and creates `kosh/logs` if it does not exist.
82    /// 3. Opens (or creates) the current day's log file in append mode.
83    /// 4. Creates an unbound Unix Datagram Socket for broadcasting.
84    /// 5. Spawns a dedicated `tokio` task that runs the [`Service::run`] loop.
85    ///
86    /// The returned [`Sender`] should be stored in [`GLOBAL_LOGGER`] immediately after
87    /// this call. The returned [`LoggerHandler`] should be kept alive and awaited during
88    /// graceful shutdown via [`LoggerHandler::shutdown_with_grace`].
89    ///
90    /// # Errors
91    ///
92    /// Returns [`error::Error::LogDirectoryInitialization`] if the XDG state directory
93    /// cannot be determined. Returns [`error::Error::Io`] if the log directory cannot be
94    /// created or the initial log file cannot be opened.
95    pub async fn start(
96        capacity: usize,
97    ) -> Result<(Sender<Entry>, LoggerHandler)> {
98        let (sender, receiver) = channel(capacity);
99
100        let log_path = dirs::state_dir()
101            .ok_or(LogDirectoryInitialization)?
102            .join("kosh")
103            .join("logs");
104
105        create_dir_all(&log_path).await?;
106
107        let time = chrono::Utc::now().timestamp_millis();
108
109        let active_file = fs::OpenOptions::new()
110            .append(true)
111            .create(true)
112            .open(&log_path.join(format_date_time(time)))
113            .await?;
114
115        let socket = UnixDatagram::unbound()?;
116
117        let service = Self {
118            receiver,
119            log_path,
120            today: time / DAY_MILLIS,
121            active_file,
122            socket,
123        };
124
125        let task = spawn(async move { service.run().await });
126        Ok((sender, LoggerHandler(task)))
127    }
128
129    #[must_use]
130    pub fn path() -> Option<PathBuf> {
131        dirs::state_dir().map(|x| x.join("kosh").join("logs"))
132    }
133
134    /// The main receive loop of the logging service.
135    ///
136    /// Runs until a [`Level::Shutdown`] entry is received, at which point it returns
137    /// and the spawned task completes, allowing [`LoggerHandler::shutdown_with_grace`]
138    /// to join cleanly.
139    ///
140    /// Errors from [`Service::commit`] (disk write failures, serialization failures)
141    /// are printed to `stderr` using `eprintln!` rather than being propagated. This
142    /// ensures that a transient I/O error does not terminate the logging service.
143    async fn run(mut self) {
144        while let Some(entry) = self.receiver.recv().await {
145            if Level::Shutdown == entry.level {
146                let _ = self.commit(entry).await;
147                return;
148            }
149            if let Err(e) = self.commit(entry).await {
150                eprintln!("Failed to commit log : {e}");
151            }
152        }
153    }
154
155    /// Serializes and persists a single log entry.
156    ///
157    /// Before writing, it checks whether the entry's `timestamp_ms` falls on a different
158    /// calendar day than the currently open file. If so, a new daily log file is opened
159    /// and `self.today` is updated. The file is identified purely by the entry's timestamp,
160    /// not by the wall clock, which prevents queue-lag from placing late-night entries into
161    /// the wrong file.
162    ///
163    /// After writing to disk, the serialized bytes are sent to the admin CLI socket.
164    /// Socket errors are silently ignored.
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if the new daily log file cannot be opened, if `bincode` serialization
169    /// fails, or if the `write_all` call to the active file fails.
170    async fn commit(&mut self, entry: Entry) -> Result<()> {
171        if entry.timestamp_ms / DAY_MILLIS != self.today {
172            self.active_file = fs::OpenOptions::new()
173                .create(true)
174                .append(true)
175                .open(self.log_path.join(format_date_time(entry.timestamp_ms)))
176                .await?;
177
178            self.today = entry.timestamp_ms / DAY_MILLIS;
179        }
180
181        let bytes = encode_to_vec(&entry, config::standard())?;
182
183        self.active_file.write_all(&bytes).await?;
184        let _ = self.socket.send_to(&bytes, SOCKET_ADDR).await;
185
186        Ok(())
187    }
188}
189
190/// Produces the filename for a daily log file given a Unix timestamp in milliseconds.
191///
192/// The filename takes the form `log_YYYY-M-D.bin`. It is derived entirely from the
193/// provided timestamp rather than from `Utc::now()`, ensuring that entries processed
194/// after midnight due to channel queue lag are still written to the correct file.
195///
196/// If the timestamp cannot be converted to a valid [`DateTime`], the function falls back
197/// to the Unix epoch (1970-01-01) via `unwrap_or_default`.
198#[must_use]
199pub fn format_date_time(time_stamp_millis: i64) -> String {
200    let time =
201        DateTime::from_timestamp_millis(time_stamp_millis).unwrap_or_default();
202    format!("log_{}-{}-{}.bin", time.year(), time.month(), time.day())
203}
204
205/// A handle to the background logging task.
206///
207/// Returned by [`Service::start`] alongside the channel sender. The caller should
208/// retain this handle and use it during the server's graceful shutdown sequence to
209/// ensure that all buffered log entries are flushed to disk before the process exits.
210pub struct LoggerHandler(JoinHandle<()>);
211
212impl LoggerHandler {
213    /// Waits for the logging task to finish, with a timeout.
214    ///
215    /// Before calling this method, the caller must send a [`Level::Shutdown`] entry
216    /// through the channel (typically via the [`crate::shutdown!`] macro) to signal the
217    /// service to exit its receive loop. This method then waits up to `secs` seconds for
218    /// the task to join.
219    ///
220    /// If the task does not finish within the grace period, a [`Level::Fatal`] log entry
221    /// is emitted (which will itself be silently dropped if the sender is gone) and the
222    /// method returns, allowing the OS to clean up the task.
223    pub async fn shutdown_with_grace(self, secs: u64) {
224        if let Err(e) = timeout(Duration::from_secs(secs), self.0).await {
225            fatal!(
226                Module::Logger,
227                "Grace period of {secs} secs, timed out, forcefully terminating engine.\n{e}"
228            );
229        }
230    }
231}
232
233#[cfg(test)]
234mod test {
235    use std::env::{self, remove_var, set_var, var_os};
236
237    use super::*;
238    use chrono::Utc;
239    use serial_test::serial;
240    use tmpdir::TmpDir;
241
242    async fn with_temp_env<F, Fut, T>(f: F) -> anyhow::Result<T>
243    where
244        F: FnOnce(PathBuf, Sender<Entry>, LoggerHandler) -> Fut,
245        Fut: Future<Output = anyhow::Result<T>>,
246    {
247        let temp_dir = TmpDir::new("kosh-test").await?;
248        let old_state_dir = var_os("XDG_STATE_HOME");
249        let result;
250
251        unsafe {
252            env::set_var("XDG_STATE_HOME", temp_dir.to_path_buf());
253
254            let (sender, log_handler) = Service::start(1000).await.unwrap();
255            result = f(temp_dir.to_path_buf(), sender, log_handler).await;
256
257            match old_state_dir {
258                Some(x) => set_var("XDG_STATE_HOME", x),
259                None => remove_var("XDG_STATE_HOME"),
260            }
261        }
262        result
263    }
264
265    #[tokio::test]
266    #[serial]
267    async fn logger_commits_multiple_entries_to_disk() -> anyhow::Result<()> {
268        with_temp_env(|tmp_path, sender, handle| async move {
269            let entry = Entry {
270                level: Level::Error,
271                module: Module::Api,
272                message: "Holy Test".into(),
273                timestamp_ms: Utc::now().timestamp_millis(),
274            };
275
276            sender
277                .send(Entry {
278                    message: "First Entry".into(),
279                    ..entry
280                })
281                .await?;
282
283            sender
284                .send(Entry {
285                    message: "Second Entry".into(),
286                    ..entry
287                })
288                .await?;
289
290            sender
291                .send(Entry {
292                    level: Level::Shutdown,
293                    ..entry
294                })
295                .await?;
296
297            let log_file = tmp_path
298                .join("kosh")
299                .join("logs")
300                .join(format_date_time(Utc::now().timestamp_millis()));
301
302            timeout(Duration::from_secs(3), handle.shutdown_with_grace(2))
303                .await?;
304
305            let file_bytes = std::fs::read(&log_file)?;
306
307            let (entry1, len1): (Entry, usize) =
308                bincode_next::decode_from_slice(
309                    &file_bytes,
310                    config::standard(),
311                )?;
312
313            let (entry2, _): (Entry, usize) = bincode_next::decode_from_slice(
314                &file_bytes[len1..],
315                config::standard(),
316            )?;
317
318            assert_eq!(entry1.message, "First Entry");
319            assert_eq!(entry2.message, "Second Entry");
320
321            Ok(())
322        })
323        .await?;
324
325        Ok(())
326    }
327
328    #[tokio::test]
329    #[serial]
330    async fn broadcasting_works_via_unix_socket() -> anyhow::Result<()> {
331        with_temp_env(|_, sender, handle| async move {
332            let _ = fs::remove_file(SOCKET_ADDR).await;
333            let recv_socket = UnixDatagram::bind(SOCKET_ADDR)?;
334
335            let mut buffer = [0u8; 512];
336
337            let entry = Entry {
338                module: Module::Api,
339                level: Level::Error,
340                message: "Bro where's socket??".into(),
341                timestamp_ms: 0,
342            };
343
344            sender.send(entry.clone()).await?;
345
346            let (len, _) = recv_socket.recv_from(&mut buffer).await?;
347
348            let entry: Entry = bincode_next::decode_from_slice(
349                &buffer[..len],
350                config::standard(),
351            )?
352            .0;
353
354            sender
355                .send(Entry {
356                    level: Level::Shutdown,
357                    ..entry
358                })
359                .await?;
360
361            handle.shutdown_with_grace(2).await;
362
363            let _ = fs::remove_file(SOCKET_ADDR).await;
364            Ok(())
365        })
366        .await?;
367
368        Ok(())
369    }
370
371    #[tokio::test]
372    #[serial_test::serial]
373    async fn file_rotation_on_every_new_day() -> anyhow::Result<()> {
374        with_temp_env(|tmp_path, sender, handle| async move {
375            let time = chrono::Utc::now();
376            let entry = Entry {
377                level: Level::Error,
378                message: "My log not My Log".into(),
379                timestamp_ms: time.timestamp_millis(),
380                module: Module::Api,
381            };
382
383            sender.send(entry.clone()).await?;
384
385            let tomorrow = time.timestamp_millis() + DAY_MILLIS * 2;
386
387            sender
388                .send(Entry {
389                    timestamp_ms: tomorrow,
390                    ..entry.clone()
391                })
392                .await?;
393
394            sender
395                .send(Entry {
396                    level: Level::Shutdown,
397                    timestamp_ms: tomorrow,
398                    ..entry
399                })
400                .await?;
401
402            handle.shutdown_with_grace(2).await;
403
404            let log_dir = tmp_path.join("kosh").join("logs");
405            let mut file_count = 0;
406
407            let mut read_dir = fs::read_dir(log_dir).await?;
408
409            while let Ok(Some(_entry)) = read_dir.next_entry().await {
410                file_count += 1;
411            }
412
413            assert_eq!(
414                file_count, 2,
415                "Expected 2 distinct log files, found {file_count}, instead"
416            );
417
418            Ok(())
419        })
420        .await?;
421
422        Ok(())
423    }
424}