Skip to main content

synd_runtime/daemon/
launch.rs

1use std::{
2    ffi::OsString,
3    fs::{File, OpenOptions},
4    path::{Path, PathBuf},
5    process::{Child, Command, ExitStatus, Stdio},
6    time::Duration,
7};
8
9use synd_api::session::DaemonSessionConfig;
10use synd_support::{dirs::SyndicationdDirs, time::humantime::HumanDuration};
11use tracing::{debug, warn};
12
13use crate::{
14    Result,
15    placement::{PlacementSpec, RUNTIME_ROOT_ENV},
16};
17
18/// Configuration for starting a daemon process for a runtime instance.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct DaemonLaunchConfig {
21    executable: DaemonExecutable,
22    log: DaemonLaunchLog,
23    session: DaemonSessionConfig,
24}
25
26impl DaemonLaunchConfig {
27    pub fn new(executable: DaemonExecutable, log: DaemonLaunchLog) -> Self {
28        Self {
29            executable,
30            log,
31            session: DaemonSessionConfig::default(),
32        }
33    }
34
35    pub fn executable(&self) -> &DaemonExecutable {
36        &self.executable
37    }
38
39    pub fn log(&self) -> &DaemonLaunchLog {
40        &self.log
41    }
42
43    pub fn session(&self) -> DaemonSessionConfig {
44        self.session
45    }
46
47    #[must_use]
48    pub fn with_log(mut self, log: DaemonLaunchLog) -> Self {
49        self.log = log;
50        self
51    }
52
53    #[must_use]
54    pub fn with_session(mut self, session: DaemonSessionConfig) -> Self {
55        self.session = session;
56        self
57    }
58
59    #[must_use]
60    pub fn with_session_lease_duration(mut self, lease_duration: Duration) -> Self {
61        self.session = self.session.with_lease_duration(lease_duration);
62        self
63    }
64
65    #[must_use]
66    pub fn with_session_idle_shutdown_grace(mut self, idle_shutdown_grace: Duration) -> Self {
67        self.session = self.session.with_idle_shutdown_grace(idle_shutdown_grace);
68        self
69    }
70}
71
72impl Default for DaemonLaunchConfig {
73    fn default() -> Self {
74        Self::new(
75            DaemonExecutable::current(),
76            DaemonLaunchLog::file(SyndicationdDirs::current().log_file()),
77        )
78    }
79}
80
81/// Executable used to start a daemon process.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct DaemonExecutable {
84    kind: DaemonExecutableKind,
85}
86
87impl DaemonExecutable {
88    pub fn current() -> Self {
89        Self {
90            kind: DaemonExecutableKind::Current,
91        }
92    }
93
94    pub fn path(path: impl Into<PathBuf>) -> Self {
95        Self {
96            kind: DaemonExecutableKind::Path(path.into()),
97        }
98    }
99
100    fn resolve(&self) -> Result<PathBuf> {
101        Ok(match &self.kind {
102            DaemonExecutableKind::Current => std::env::current_exe()?,
103            DaemonExecutableKind::Path(path) => path.clone(),
104        })
105    }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109enum DaemonExecutableKind {
110    Current,
111    Path(PathBuf),
112}
113
114/// File target for daemon stdout and stderr.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct DaemonLaunchLog {
117    path: PathBuf,
118}
119
120impl DaemonLaunchLog {
121    pub fn file(path: impl Into<PathBuf>) -> Self {
122        Self { path: path.into() }
123    }
124
125    pub fn path(&self) -> &Path {
126        &self.path
127    }
128
129    fn open(&self) -> Result<OpenedDaemonLaunchLog> {
130        if let Some(parent) = self.path.parent() {
131            std::fs::create_dir_all(parent)?;
132        }
133
134        let stdout = OpenOptions::new()
135            .append(true)
136            .create(true)
137            .open(&self.path)?;
138        let stderr = stdout.try_clone()?;
139
140        Ok(OpenedDaemonLaunchLog { stdout, stderr })
141    }
142}
143
144struct OpenedDaemonLaunchLog {
145    stdout: File,
146    stderr: File,
147}
148
149/// Information about a daemon process launch attempt.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct DaemonLaunchInfo {
152    executable: PathBuf,
153    arguments: Vec<OsString>,
154    environment: Vec<(OsString, OsString)>,
155    log: PathBuf,
156}
157
158impl DaemonLaunchInfo {
159    fn new(
160        executable: PathBuf,
161        arguments: Vec<OsString>,
162        environment: Vec<(OsString, OsString)>,
163        log: PathBuf,
164    ) -> Self {
165        Self {
166            executable,
167            arguments,
168            environment,
169            log,
170        }
171    }
172
173    pub fn executable(&self) -> &Path {
174        &self.executable
175    }
176
177    pub fn arguments(&self) -> &[OsString] {
178        &self.arguments
179    }
180
181    pub fn environment(&self) -> &[(OsString, OsString)] {
182        &self.environment
183    }
184
185    pub fn log(&self) -> &Path {
186        &self.log
187    }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
191struct ResolvedDaemonLaunchCommand {
192    executable: PathBuf,
193    arguments: DaemonServeArguments,
194}
195
196impl ResolvedDaemonLaunchCommand {
197    fn daemon_serve(
198        executable: PathBuf,
199        database_path: &Path,
200        session: DaemonSessionConfig,
201    ) -> Self {
202        Self {
203            executable,
204            arguments: DaemonServeArguments::new(database_path, session),
205        }
206    }
207}
208
209/// Environment variables passed to a spawned daemon process.
210#[derive(Debug, Clone, PartialEq, Eq)]
211struct DaemonLaunchEnvironment {
212    values: Vec<(OsString, OsString)>,
213}
214
215impl DaemonLaunchEnvironment {
216    fn from_placement(placement: &PlacementSpec) -> Self {
217        Self::runtime_root(placement.root().path())
218    }
219
220    fn runtime_root(root: &Path) -> Self {
221        Self {
222            values: vec![(
223                OsString::from(RUNTIME_ROOT_ENV),
224                root.as_os_str().to_os_string(),
225            )],
226        }
227    }
228
229    fn apply_to(&self, command: &mut Command) {
230        for (key, value) in &self.values {
231            command.env(key, value);
232        }
233    }
234
235    fn as_slice(&self) -> &[(OsString, OsString)] {
236        &self.values
237    }
238}
239
240/// Argument vector for invoking `synd daemon serve`.
241#[derive(Debug, Clone, PartialEq, Eq)]
242struct DaemonServeArguments {
243    values: Vec<OsString>,
244}
245
246impl DaemonServeArguments {
247    fn new(database_path: &Path, session: DaemonSessionConfig) -> Self {
248        let mut values = vec![
249            OsString::from("daemon"),
250            OsString::from("serve"),
251            OsString::from("--sqlite-db"),
252            database_path.as_os_str().to_os_string(),
253        ];
254        values.extend(DaemonSessionServeArguments::from(session));
255
256        Self { values }
257    }
258
259    fn as_slice(&self) -> &[OsString] {
260        &self.values
261    }
262}
263
264/// Session-related argument vector fragment for `synd daemon serve`.
265#[derive(Debug, Clone, PartialEq, Eq)]
266struct DaemonSessionServeArguments {
267    lease_duration: Duration,
268    idle_shutdown_grace: Duration,
269}
270
271impl From<DaemonSessionConfig> for DaemonSessionServeArguments {
272    fn from(config: DaemonSessionConfig) -> Self {
273        Self {
274            lease_duration: config.lease_policy().lease_duration(),
275            idle_shutdown_grace: config.idle_shutdown_grace(),
276        }
277    }
278}
279
280impl IntoIterator for DaemonSessionServeArguments {
281    type Item = OsString;
282    type IntoIter = std::vec::IntoIter<Self::Item>;
283
284    fn into_iter(self) -> Self::IntoIter {
285        vec![
286            OsString::from("--session-lease-duration"),
287            OsString::from(String::from(HumanDuration::from(self.lease_duration))),
288            OsString::from("--session-idle-shutdown-grace"),
289            OsString::from(String::from(HumanDuration::from(self.idle_shutdown_grace))),
290        ]
291        .into_iter()
292    }
293}
294
295/// Starts a daemon process for a resolved runtime placement.
296pub(crate) struct DaemonLauncher<'a> {
297    config: &'a DaemonLaunchConfig,
298    placement: PlacementSpec,
299}
300
301impl<'a> DaemonLauncher<'a> {
302    pub(crate) fn new(config: &'a DaemonLaunchConfig, placement: PlacementSpec) -> Self {
303        Self { config, placement }
304    }
305
306    pub(crate) fn launch(self) -> Result<DaemonHandle> {
307        let command = ResolvedDaemonLaunchCommand::daemon_serve(
308            self.config.executable().resolve()?,
309            self.placement.instance().canonical_database_path(),
310            self.config.session(),
311        );
312        let environment = DaemonLaunchEnvironment::from_placement(&self.placement);
313        let log = self.config.log().open()?;
314        let launch = DaemonLaunchInfo::new(
315            command.executable.clone(),
316            command.arguments.as_slice().to_vec(),
317            environment.as_slice().to_vec(),
318            self.config.log().path().to_path_buf(),
319        );
320        debug!(
321            daemon_launch = ?launch,
322            "Launching daemon"
323        );
324
325        let mut child_command = Command::new(&command.executable);
326        child_command.args(command.arguments.as_slice());
327        environment.apply_to(&mut child_command);
328        let child = child_command
329            .stdin(Stdio::null())
330            .stdout(Stdio::from(log.stdout))
331            .stderr(Stdio::from(log.stderr))
332            .spawn()?;
333
334        Ok(DaemonHandle { child, launch })
335    }
336}
337
338/// Handle for a daemon process spawned by session acquisition.
339pub(crate) struct DaemonHandle {
340    child: Child,
341    launch: DaemonLaunchInfo,
342}
343
344impl DaemonHandle {
345    pub(crate) fn launch(&self) -> &DaemonLaunchInfo {
346        &self.launch
347    }
348
349    pub(crate) fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
350        Ok(self.child.try_wait()?)
351    }
352
353    pub(crate) fn reap_in_background(mut self) {
354        std::thread::spawn(move || match self.child.wait() {
355            Ok(status) => {
356                debug!(%status, "Daemon process exited");
357            }
358            Err(error) => {
359                warn!(%error, "Failed to wait for daemon process");
360            }
361        });
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use std::{ffi::OsString, path::Path, time::Duration};
368
369    use synd_api::session::{DaemonSessionConfig, DaemonSessionLeasePolicy};
370
371    use super::{
372        DaemonExecutable, DaemonLaunchEnvironment, DaemonLaunchLog, ResolvedDaemonLaunchCommand,
373    };
374
375    mod daemon_serve_command {
376        use super::*;
377
378        #[test]
379        fn uses_database_path() {
380            let command = ResolvedDaemonLaunchCommand::daemon_serve(
381                DaemonExecutable::path("/usr/bin/synd").resolve().unwrap(),
382                Path::new("/tmp/synd.db"),
383                DaemonSessionConfig::default(),
384            );
385
386            assert_eq!(command.executable, Path::new("/usr/bin/synd"));
387            assert_eq!(
388                command.arguments.as_slice(),
389                [
390                    OsString::from("daemon"),
391                    OsString::from("serve"),
392                    OsString::from("--sqlite-db"),
393                    OsString::from("/tmp/synd.db"),
394                    OsString::from("--session-lease-duration"),
395                    OsString::from("30s"),
396                    OsString::from("--session-idle-shutdown-grace"),
397                    OsString::from("30s"),
398                ]
399            );
400        }
401
402        #[test]
403        fn includes_session_config() {
404            let command = ResolvedDaemonLaunchCommand::daemon_serve(
405                DaemonExecutable::path("/usr/bin/synd").resolve().unwrap(),
406                Path::new("/tmp/synd.db"),
407                DaemonSessionConfig::new(
408                    DaemonSessionLeasePolicy::new(Duration::from_mins(1), Duration::from_secs(5)),
409                    Duration::from_mins(2),
410                ),
411            );
412
413            assert_eq!(
414                command.arguments.as_slice(),
415                [
416                    OsString::from("daemon"),
417                    OsString::from("serve"),
418                    OsString::from("--sqlite-db"),
419                    OsString::from("/tmp/synd.db"),
420                    OsString::from("--session-lease-duration"),
421                    OsString::from("1m"),
422                    OsString::from("--session-idle-shutdown-grace"),
423                    OsString::from("2m"),
424                ]
425            );
426        }
427    }
428
429    mod launch_log {
430        use super::*;
431
432        #[test]
433        fn creates_parent_dir() {
434            let tmp = tempfile::tempdir().unwrap();
435            let log = DaemonLaunchLog::file(tmp.path().join("nested").join("daemon.log"));
436
437            let _opened = log.open().unwrap();
438
439            assert!(log.path().exists());
440        }
441    }
442
443    mod launch_environment {
444        use super::*;
445
446        #[test]
447        fn includes_runtime_root() {
448            let environment = DaemonLaunchEnvironment::runtime_root(Path::new("/tmp/synd-runtime"));
449
450            assert_eq!(
451                environment.as_slice(),
452                [(
453                    OsString::from(crate::placement::RUNTIME_ROOT_ENV),
454                    OsString::from("/tmp/synd-runtime"),
455                )]
456            );
457        }
458    }
459}