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}