Skip to main content

git_policy_cli/service/
service_provision.rs

1//! What:
2//!  The wrapper makes socket activation for a worktree's commit service exist the first time a commit needs it: it writes
3//!  the two template unit files into the user's runtime unit directory, asks the user manager (through the session bus) to
4//!  reload and to start the worktree's socket instance, and returns. The manager, not the wrapper, starts the service when
5//!  the first connection arrives.
6//! Why:
7//!  There is no install step to remember (`doc/planning/cli-git-resident-service.md`, "Provisioning"): like Bazel's
8//!  client, the wrapper sets up what it needs and tells nobody. One service runs per worktree, so the units are templates
9//!  (`cli-git@.socket`, `cli-git@.service`) and each worktree's instance is named by a hash of its own Git directory
10//!  (`service_instance.rs`). The units live under `$XDG_RUNTIME_DIR/systemd/user`, which disappears at logout, so nothing is
11//!  left behind in the home directory and the next first use provisions again. Every step is idempotent (a file is written,
12//!  by temporary file and rename, only when its content differs), so several commits racing to provision agree without any
13//!  lock. Every commit that goes to the service compares the two installed templates with what this wrapper would write
14//!  (two small reads) before it touches the bus, so an upgrade or a downgrade, which moves the service program the template
15//!  names, is repaired by the first commit that follows it: templates rewritten, manager reloaded, failed state cleared,
16//!  the worktree's socket started. The single service of the earlier releases (`cli-git.socket` and `cli-git.service`, one
17//!  socket for every worktree) is asked to retire and stopped, and its units and socket file are removed, by the first
18//!  commit of the new release; its open batches are finished by the instance of the worktree they belong to, and the work
19//!  it had claimed is claimed again by the next drain. Units this wrapper's earliest releases wrote into
20//!  `~/.config/systemd/user` outrank the runtime ones in the unit search path, so they are recognized by their content and
21//!  removed; a unit of the same name that something else wrote is never touched, and the commit stops with a message naming
22//!  the file.
23//!
24//! In TS you'd write (pseudocode):
25//! ```ts
26//! await provision({ runtimeDir, configHome, busPath, serviceBinary, git, instance }) // writes templates, reloads, starts the socket
27//! ```
28
29use crate::service_messages::{ClientFailure, Platform};
30use crate::service_client::ask_to_retire;
31use crate::service_instance::{LEGACY_SOCKET_RELATIVE, service_unit_instance, socket_path, socket_unit_instance};
32use crate::systemd_dbus::{Bus, CallError, EXCHANGE_WAIT, NO_SUCH_UNIT};
33use std::path::{Path, PathBuf};
34
35/// The socket template's text, with the marker on its first line.
36pub const SOCKET_UNIT: &str = include_str!("service_units/cli-git@.socket");
37
38/// The service template's text, with `@SERVICE_BINARY@` and `@GIT@` still to fill in.
39pub const SERVICE_UNIT: &str = include_str!("service_units/cli-git@.service");
40
41/// File name of the socket template.
42pub const SOCKET_UNIT_NAME: &str = "cli-git@.socket";
43
44/// File name of the service template.
45pub const SERVICE_UNIT_NAME: &str = "cli-git@.service";
46
47/// File name of the socket unit of the single service every worktree shared until one service per worktree.
48pub const LEGACY_SOCKET_UNIT_NAME: &str = "cli-git.socket";
49
50/// File name of the service unit of the single service every worktree shared until one service per worktree.
51pub const LEGACY_SERVICE_UNIT_NAME: &str = "cli-git.service";
52
53/// Name of the service program, next to the wrapper.
54pub const SERVICE_PROGRAM: &str = "cli-git-service";
55
56/// The words that mark a unit file as written by this wrapper: the marker its units carry now, and the descriptions the
57///  units of earlier releases carried.
58pub const OWN_MARKERS: [&str; 3] =
59    ["(cli-git-unit)", "Description=Commit service socket for the git wrapper", "Description=Commit service for the git wrapper"];
60
61const BINARY_PLACEHOLDER: &str = "@SERVICE_BINARY@";
62const GIT_PLACEHOLDER: &str = "@GIT@";
63
64/// What provisioning needs to know about the machine.
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub struct ProvisionHost {
67    /// The user's runtime directory, which holds the units and the service's socket.
68    pub runtime_dir: PathBuf,
69    /// The directory a user's own unit files live in (`~/.config/systemd/user`), where earlier releases wrote theirs.
70    pub user_unit_dir: Option<PathBuf>,
71    /// The session bus socket.
72    pub bus: PathBuf,
73    /// The service program.
74    pub service_binary: PathBuf,
75    /// The real Git the service runs.
76    pub git: PathBuf,
77    /// The instance of the worktree the commit is made in (`service_instance::instance_of`).
78    pub instance: String,
79    /// How long retiring the earlier single service may take before the commit fails instead of starting an instance beside it.
80    pub retirement_wait: std::time::Duration,
81}
82
83/// How long the earlier single service may take to stop; it finishes the batch it holds first. The wait and every question to
84///  the manager in it share this one budget, set apart from the exchange's own, so a slow stop is reported as that and not as
85///  an unreachable manager.
86pub const RETIREMENT_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
87
88/// What the manager is asked to do, so a control can record it instead of reaching a manager.
89pub trait Manager {
90    /// Gives the calls that follow `wait` from now in all, so that each phase of provisioning has a time of its own.
91    fn within(&mut self, wait: std::time::Duration);
92    /// Reloads the unit files.
93    fn reload(&mut self) -> Result<(), CallError>;
94    /// Clears a unit's failed state and start-rate counter.
95    fn reset_failed(&mut self, unit: &str) -> Result<(), CallError>;
96    /// Starts a unit.
97    fn start(&mut self, unit: &str) -> Result<(), CallError>;
98    /// Stops a unit.
99    fn stop(&mut self, unit: &str) -> Result<(), CallError>;
100    /// The unit's state as the manager reports it (`active`, `deactivating`, `inactive`, `failed`), or nothing when the
101    ///  manager holds no such unit.
102    fn active_state(&mut self, unit: &str) -> Result<Option<String>, CallError>;
103}
104
105impl Manager for Bus {
106    fn within(&mut self, wait: std::time::Duration) {
107        Bus::within(self, wait);
108    }
109
110    fn reload(&mut self) -> Result<(), CallError> {
111        return Bus::reload(self);
112    }
113
114    fn reset_failed(&mut self, unit: &str) -> Result<(), CallError> {
115        return Bus::reset_failed(self, unit);
116    }
117
118    fn start(&mut self, unit: &str) -> Result<(), CallError> {
119        return Bus::start_unit(self, unit);
120    }
121
122    fn stop(&mut self, unit: &str) -> Result<(), CallError> {
123        return Bus::stop_unit(self, unit);
124    }
125
126    fn active_state(&mut self, unit: &str) -> Result<Option<String>, CallError> {
127        return Bus::active_state(self, unit);
128    }
129}
130
131/// The platform whose remedy applies when no user service manager answers.
132pub fn platform_without_manager() -> Platform {
133    if cfg!(windows) {
134        return Platform::Windows;
135    }
136    if cfg!(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly")) {
137        return Platform::Bsd;
138    }
139    if cfg!(target_os = "linux") {
140        return Platform::LinuxWithoutUserServices;
141    }
142    return Platform::Other;
143}
144
145/// The two unit files for a host, as written.
146pub fn unit_texts(host: &ProvisionHost) -> [(&'static str, String); 2] {
147    let service: String = SERVICE_UNIT
148        .replace(BINARY_PLACEHOLDER, host.service_binary.to_string_lossy().as_ref())
149        .replace(GIT_PLACEHOLDER, host.git.to_string_lossy().as_ref());
150    return [(SOCKET_UNIT_NAME, SOCKET_UNIT.to_string()), (SERVICE_UNIT_NAME, service)];
151}
152
153/// The directory the runtime units are written to.
154pub fn runtime_unit_dir(runtime_dir: &Path) -> PathBuf {
155    return runtime_dir.join("systemd").join("user");
156}
157
158/// What:
159///  Whether the installed templates are the ones this wrapper would write, the worktree's socket is there, and nothing of
160///  the single service of the earlier releases is left.
161/// Why:
162///   The socket belongs to the manager and outlives every upgrade, so its presence says nothing about which program the
163///  service template names; the text does. A few small reads and two `stat` calls decide, and the bus is not touched when
164///  they agree.
165///
166/// In TS you'd write (pseudocode):
167/// ```ts
168/// inPlace(host) // false: write, reload, start
169/// ```
170pub fn in_place(host: &ProvisionHost) -> bool {
171    let directory: PathBuf = runtime_unit_dir(host.runtime_dir.as_path());
172    let current = |(name, text): (&str, String)| -> bool {
173        return std::fs::read_to_string(directory.join(name)).is_ok_and(|installed| { return installed == text; });
174    };
175    // A file of an earlier name that something else wrote is not the wrapper's to retire, and does not keep it from being in place.
176    let earlier_left: bool = [LEGACY_SOCKET_UNIT_NAME, LEGACY_SERVICE_UNIT_NAME].iter().any(|name| {
177        return std::iter::once(&directory).chain(host.user_unit_dir.iter()).any(|found| { return is_own_unit_file(found.join(name).as_path()); });
178    }) || host.runtime_dir.join(LEGACY_SOCKET_RELATIVE).exists();
179    return unit_texts(host).into_iter().all(current)
180        && socket_path(host.runtime_dir.as_path(), host.instance.as_str()).exists()
181        && !earlier_left;
182}
183
184static TEMPORARY_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
185
186/// Writes `text` to `path` through a temporary file and a rename, so a reader sees the old file or the new one whole.
187fn write_atomically(path: &Path, text: &str) -> std::io::Result<()> {
188    let number: u64 = TEMPORARY_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
189    let temporary: PathBuf = path.with_extension(format!("tmp-{}-{number}", std::process::id()));
190    std::fs::write(&temporary, text.as_bytes())?;
191    let renamed: std::io::Result<()> = std::fs::rename(&temporary, path);
192    if renamed.is_err() {
193        let _removed = std::fs::remove_file(&temporary);
194    }
195    return renamed;
196}
197
198/// What the user's own unit directory holds under the names the wrapper uses.
199fn clear_earlier_units(host: &ProvisionHost) -> Result<bool, ClientFailure> {
200    let Some(directory) = host.user_unit_dir.as_deref() else { return Ok(false) };
201    let mut removed: bool = false;
202    for name in [LEGACY_SOCKET_UNIT_NAME, LEGACY_SERVICE_UNIT_NAME] {
203        let path: PathBuf = directory.join(name);
204        let Ok(text) = std::fs::read_to_string(&path) else {
205            if path.exists() {
206                return Err(ClientFailure::UnitInTheWay(path.display().to_string()));
207            }
208            continue;
209        };
210        if !OWN_MARKERS.iter().any(|marker| { return text.contains(marker); }) {
211            return Err(ClientFailure::UnitInTheWay(path.display().to_string()));
212        }
213        remove_settled(path.as_path())?;
214        removed = true;
215    }
216    // The enabling link an earlier release made points at the file just removed.
217    let link: PathBuf = directory.join("sockets.target.wants").join(LEGACY_SOCKET_UNIT_NAME);
218    if std::fs::symlink_metadata(&link).is_ok_and(|meta| { return meta.file_type().is_symlink(); }) {
219        let _removed = std::fs::remove_file(&link);
220        removed = true;
221    }
222    return Ok(removed);
223}
224
225/// What:
226///  Retires the single service of the earlier releases: asks it to finish its batch and exit, stops its units, and removes
227///  its unit files and socket file. Whether the manager must reload.
228/// Why:
229///   One service for every worktree is what the owner replaced with one per worktree. Its socket and units belong to the
230///  runtime directory, which the wrapper owns; a unit file that does not carry the wrapper's marker is someone else's and is
231///  left alone. Work it had claimed stays in the work directories and is claimed again by the instance of its worktree; the
232///  batches it left open are in its journal, where the instance of their worktree finishes them.
233///
234/// In TS you'd write (pseudocode):
235/// ```ts
236/// await retireEarlierService(host, manager) // true: reload
237/// ```
238fn retire_earlier_service(host: &ProvisionHost, manager: &mut dyn Manager) -> Result<bool, ClientFailure> {
239    let directory: PathBuf = runtime_unit_dir(host.runtime_dir.as_path());
240    // The releases that wrote into the user's own directory left their service running just the same.
241    let directories: Vec<PathBuf> = std::iter::once(directory.clone()).chain(host.user_unit_dir.clone()).collect();
242    let socket_file: PathBuf = host.runtime_dir.join(LEGACY_SOCKET_RELATIVE);
243    let named_anywhere = |name: &str| -> bool { return directories.iter().any(|found| { return found.join(name).exists(); }); };
244    let both: [&str; 2] = [LEGACY_SOCKET_UNIT_NAME, LEGACY_SERVICE_UNIT_NAME];
245    // Only a unit whose definition the manager reads is ours to stop: a file of the same name in the runtime directory
246    //  does not make a unit that the user's own directory defines ours.
247    let mut units: Vec<&str> = both.into_iter().filter(|name| { return effective_unit_file(host, name).is_some_and(|file| { return is_own_unit_file(file.as_path()); }); }).collect();
248    // Only a file that carries the marker is ours to remove, whichever file defines the unit.
249    let owned_files: Vec<PathBuf> = both.into_iter().map(|name| { return directory.join(name); }).filter(|file| { return is_own_unit_file(file.as_path()); }).collect();
250    // A socket file with no unit file of that name anywhere is what a release that removed the files but not the service left
251    //  behind: the service may still run, so both names are stopped. A file of that name that is someone else's blocks this.
252    if units.is_empty() && owned_files.is_empty() && socket_file.exists() && !both.iter().any(|name| { return named_anywhere(name); }) {
253        units = both.to_vec();
254    }
255    if units.is_empty() && owned_files.is_empty() {
256        return Ok(false);
257    }
258    if !units.is_empty() {
259        ask_to_retire(socket_file.as_path());
260        // The time spent asking the earlier service to retire is not taken from the stop calls.
261        manager.within(EXCHANGE_WAIT);
262        // The socket first, so that the manager cannot start the service again for a connection that arrives meanwhile.
263        let (sockets, services): (Vec<&str>, Vec<&str>) = units.iter().partition(|name| { return **name == LEGACY_SOCKET_UNIT_NAME; });
264        stop_units(manager, sockets.as_slice())?;
265        // The manager's stop ends the whole unit, the Git children of a landing in progress with it, and a Git step that cannot run
266        //  twice may be one of them. The service is asked to retire, which it handles between batches, and is given the wait to
267        //  leave by itself; the stop goes to a service that has left, and a service that is still there when the wait ends fails
268        //  the commit with the sentence that names the stop for the person to choose (sol-1 round 2).
269        wait_until_stopped(host, manager, services.as_slice())?;
270        stop_units(manager, services.as_slice())?;
271        wait_until_stopped(host, manager, units.as_slice())?;
272    }
273    for file in &owned_files {
274        remove_settled(file.as_path())?;
275    }
276    let _socket_removed = std::fs::remove_file(&socket_file);
277    return Ok(!owned_files.is_empty());
278}
279
280/// Asks the manager to stop each unit; a unit it never loaded has nothing to stop.
281fn stop_units(manager: &mut dyn Manager, units: &[&str]) -> Result<(), ClientFailure> {
282    for name in units {
283        match manager.stop(name) {
284            Ok(()) => {}
285            Err(CallError::Manager { name: error, .. }) if error == NO_SUCH_UNIT => {}
286            Err(error) => return Err(refusal(error)),
287        }
288    }
289    return Ok(());
290}
291
292/// Whether the unit file at `path` carries a marker of this wrapper's units.
293fn is_own_unit_file(path: &Path) -> bool {
294    return std::fs::read_to_string(path).is_ok_and(|text| { return OWN_MARKERS.iter().any(|marker| { return text.contains(marker); }); });
295}
296
297/// What:
298///  The file that defines the unit `name` for the user's manager: the one in the user's own unit directory when there is
299///  one, else the one in the runtime unit directory.
300/// Why:
301///   The user's own directory outranks the runtime one in the manager's search path (`systemd.unit(5)`, "Unit File Load
302///  Path"), so a runtime file of the same name is shadowed and says nothing about the unit that runs.
303///
304/// In TS you'd write (pseudocode):
305/// ```ts
306/// effectiveUnitFile(host, 'cli-git.service') // the path the manager reads
307/// ```
308fn effective_unit_file(host: &ProvisionHost, name: &str) -> Option<PathBuf> {
309    return host
310        .user_unit_dir
311        .iter()
312        .map(|directory| { return directory.join(name); })
313        .chain(std::iter::once(runtime_unit_dir(host.runtime_dir.as_path()).join(name)))
314        .find(|file| { return file.exists(); });
315}
316
317/// What:
318///  Removes a unit file whose ownership was just checked; a file that is gone by now is a removal someone else finished.
319/// Why:
320///   Several commits provision at once without any lock, so two of them can read the same owned file and both remove it:
321///  the second one's removal finds nothing, and the cleanup it wanted is done.
322///
323/// In TS you'd write (pseudocode):
324/// ```ts
325/// await rm(path, { force: true }) // other failures throw
326/// ```
327fn remove_settled(path: &Path) -> Result<(), ClientFailure> {
328    return match std::fs::remove_file(path) {
329        Ok(()) => Ok(()),
330        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
331        Err(error) => Err(ClientFailure::CouldNotStart(format!("{} cannot be removed: {error}", path.display()))),
332    };
333}
334
335/// What:
336///  Waits until the manager reports each unit inactive, failed or not held, and fails when that takes longer than the host
337///  allows.
338/// Why:
339///   A stop call returns when the job is queued, and a service that was asked to retire finishes the batch it holds, and its Git
340///  children, before it exits (the wait comes before the stop of the service, which would cut that batch); an instance started meanwhile would recover and claim work the earlier service is still doing, so the commit
341///  fails instead, naming what is still stopping. Its files stay, so the next commit finds the same state and tries again.
342///
343/// In TS you'd write (pseudocode):
344/// ```ts
345/// await waitUntilStopped(host, manager, ['cli-git.socket', 'cli-git.service'])
346/// ```
347fn wait_until_stopped(host: &ProvisionHost, manager: &mut dyn Manager, units: &[&str]) -> Result<(), ClientFailure> {
348    let deadline: std::time::Instant = std::time::Instant::now() + host.retirement_wait;
349    let still_stopping = |unit: &str| -> ClientFailure {
350        return ClientFailure::CouldNotStart(format!(
351            "the earlier commit service ({unit}) is still running after {:.1} seconds, and it may be in the middle of a landing that stopping it would cut; run the commit again in a moment, or, when no commit is running, stop it with `systemctl --user stop {LEGACY_SERVICE_UNIT_NAME} {LEGACY_SOCKET_UNIT_NAME}`",
352            host.retirement_wait.as_secs_f64()
353        ));
354    };
355    loop {
356        let mut running: Option<&str> = None;
357        for name in units {
358            // A question to the manager may not outlast the wait: it is given what is left of it, and a question that runs out
359            //  of that is a service that is still stopping, not a manager that is not there.
360            manager.within(deadline.saturating_duration_since(std::time::Instant::now()));
361            let state: Option<String> = match manager.active_state(name) {
362                Ok(found) => found,
363                Err(CallError::TimedOut) => return Err(still_stopping(name)),
364                Err(error) => return Err(refusal(error)),
365            };
366            if state.is_some_and(|text| { return !matches!(text.as_str(), "inactive" | "failed"); }) {
367                running = Some(name);
368            }
369        }
370        let Some(unit) = running else { return Ok(()) };
371        if std::time::Instant::now() >= deadline {
372            return Err(still_stopping(unit));
373        }
374        std::thread::sleep(RETIREMENT_POLL);
375    }
376}
377
378/// How often the manager is asked whether the earlier service has stopped.
379const RETIREMENT_POLL: std::time::Duration = std::time::Duration::from_millis(50);
380
381/// What a refused call is to the person: the manager's own words, or a plain statement; its error name is internal vocabulary.
382fn refusal(error: CallError) -> ClientFailure {
383    return match error {
384        CallError::Manager { text, .. } if text.is_empty() => ClientFailure::CouldNotStart(String::from("the service manager refused the request")),
385        CallError::Manager { text, .. } => ClientFailure::CouldNotStart(format!("the service manager refused the request ({text})")),
386        CallError::Connection(_) | CallError::Authentication(_) | CallError::Malformed(_) | CallError::TimedOut => {
387            ClientFailure::NoActivation(platform_without_manager())
388        }
389    };
390}
391
392/// What:
393///  Makes socket activation exist: the earlier single service retired, current templates written where they differ, the
394///  manager reloaded when a unit it may hold changed or went away, and the worktree's socket instance started.
395/// Why:
396///   Idempotent by construction: a second call with nothing to change only starts the socket, which is a no-op when it is
397///  already listening; two calls at once write identical content through renames and both succeed.
398///
399/// In TS you'd write (pseudocode):
400/// ```ts
401/// await provisionWith(host, manager)
402/// ```
403pub fn provision_with(host: &ProvisionHost, manager: &mut dyn Manager) -> Result<(), ClientFailure> {
404    // A reload makes the manager re-read every unit and interrupts its other clients for a while, so it is asked for only
405    //  when a unit it may already hold changed or went away. A file that is new is found by the manager when the unit is
406    //  first started.
407    let mut reload: bool = retire_earlier_service(host, manager)?;
408    reload |= clear_earlier_units(host)?;
409    let directory: PathBuf = runtime_unit_dir(host.runtime_dir.as_path());
410    std::fs::create_dir_all(&directory).map_err(|error| { return ClientFailure::CouldNotStart(format!("{} cannot be created: {error}", directory.display())); })?;
411    // Whether the manager may hold state for these units: a unit file was already there, so a start may have failed under it.
412    let mut known: bool = false;
413    let mut written: Vec<PathBuf> = Vec::new();
414    for (name, text) in unit_texts(host) {
415        let path: PathBuf = directory.join(name);
416        let existed: bool = path.exists();
417        known |= existed;
418        if std::fs::read_to_string(&path).is_ok_and(|current| { return current == text; }) {
419            continue;
420        }
421        write_atomically(&path, text.as_str())
422            .map_err(|error| { return ClientFailure::CouldNotStart(format!("{} cannot be written: {error}", path.display())); })?;
423        written.push(path);
424        reload |= existed;
425    }
426    // Whatever the retirement of the earlier service took, the reload and the start have the time of a whole exchange.
427    manager.within(EXCHANGE_WAIT);
428    let asked: Result<(), CallError> = ask_manager(manager, reload, known, host.instance.as_str());
429    if let Err(error) = asked {
430        // A file the manager never heard about must not look installed to the next commit, which would then skip the
431        //  reload that this one did not complete.
432        for path in &written {
433            let _removed = std::fs::remove_file(path);
434        }
435        return Err(refusal(error));
436    }
437    return Ok(());
438}
439
440/// The calls provisioning makes: a reload when a held unit changed, a reset of failed state when a unit may have failed
441///  under the manager (the reset is best effort: a unit the manager does not hold has nothing to reset), and the start.
442fn ask_manager(manager: &mut dyn Manager, reload: bool, known: bool, instance: &str) -> Result<(), CallError> {
443    if reload {
444        manager.reload()?;
445    }
446    if known {
447        // A start that failed under an old unit leaves the service failed and its start counter full, and the socket
448        //  failed with it; the new unit would be refused as "repeated too quickly" until the interval passed.
449        for unit in [service_unit_instance(instance), socket_unit_instance(instance)] {
450            let _reset = manager.reset_failed(unit.as_str());
451        }
452    }
453    return manager.start(socket_unit_instance(instance).as_str());
454}
455
456/// Provisions through the session bus, unless the units and the socket are already in place.
457pub fn provision(host: &ProvisionHost) -> Result<(), ClientFailure> {
458    if in_place(host) {
459        return Ok(());
460    }
461    let mut bus: Bus = Bus::connect(host.bus.as_path(), EXCHANGE_WAIT).map_err(|_| { return ClientFailure::NoActivation(platform_without_manager()); })?;
462    return provision_with(host, &mut bus);
463}
464
465/// Provisioning controls stay out of the release executable.
466#[cfg(test)]
467#[path = "service_provision_tests.rs"]
468mod tests;