Skip to main content

git_policy_cli/
service_client.rs

1//! What:
2//!  The wrapper's side of a commit through the resident service: connect, wait honestly while the operating system
3//!  starts the service, send the request, show the queue position, survive a service that dies mid-request, and turn
4//!  every failure into the plain text of `service_messages.rs`.
5//! Why:
6//!  A failure the system can resolve is resolved here without the user: a service that is starting is waited for, one
7//!  that crashed is reconnected to with the same request identity, one that was started from another release of the
8//!  wrapper (older or newer, found by the release its welcome names) is asked to retire and is reached again once the
9//!  manager has started the current one. Only what cannot be resolved ends the command.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const result = await submit(config, request, stderr); // landed, or prepare again, or a ClientFailure
14//! ```
15
16use super::commit_timing::{ReachTrace, SubmitTrace};
17use super::service_messages::{ClientFailure, Platform, STARTING_NOTICE, UPDATING_NOTICE, position_notice};
18use super::service_protocol::{
19    ClientMessage, CommitRequest, FailureCode, LandedCapture, PROTOCOL_VERSION, RELEASE, ServiceMessage, decode_service, encode_client, encode_retire,
20};
21use super::service_review_key::ReviewKey;
22use super::service_server::Bind;
23use std::io::{BufRead, BufReader, Write};
24use std::os::unix::net::UnixStream;
25use std::time::{Duration, Instant};
26
27/// How long the client waits for the operating system to start the service.
28pub const START_WAIT: Duration = Duration::from_secs(10);
29
30/// How long a start takes before the client says it is starting the service.
31pub const START_NOTICE_GRACE: Duration = Duration::from_secs(1);
32
33/// How long a commit waits in the queue before the client gives up and says so.
34pub const COMMIT_WAIT: Duration = Duration::from_secs(10 * 60);
35
36/// How many times a request is sent again after the connection broke before the client says it cannot be sure.
37pub const RECONNECTS: usize = 5;
38
39/// The pause between connection tries while the service starts.
40const CONNECT_PAUSE: Duration = Duration::from_millis(25);
41
42/// What the client may rely on the operating system to do.
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum Activation {
45    /// The manager owns the socket and starts the service on demand.
46    Managed,
47    /// This platform cannot start the service on demand.
48    Unsupported(Platform),
49}
50
51/// What the client needs to reach the service.
52#[derive(Clone, Debug)]
53pub struct ClientConfig {
54    /// Where the service listens.
55    pub address: Bind,
56    /// What the operating system can do about a service that is not running.
57    pub activation: Activation,
58    /// Longest wait for a start.
59    pub start_wait: Duration,
60    /// Longest wait in the queue.
61    pub commit_wait: Duration,
62}
63
64/// What a request came to.
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub enum Submitted {
67    /// The commit is on the branch.
68    Landed { commit: String, already: bool },
69    /// A named path changed on the branch: prepare again from this base and submit again with the same identity.
70    ///  `landed` tells when the bytes of the commits the service landed since were captured, for the ones it knows.
71    PrepareAgain { base: String, landed: Vec<LandedCapture> },
72    /// The branch is no longer at the base of a commit that cannot be moved to another tip (an amend, or a commit Git built).
73    ///  The caller decides whether to build it again on the new tip or to stop.
74    Moved,
75}
76
77fn connect(address: &Bind) -> std::io::Result<UnixStream> {
78    return match address {
79        #[cfg(target_os = "linux")]
80        Bind::Abstract(name) => {
81            use std::os::linux::net::SocketAddrExt;
82            UnixStream::connect_addr(&std::os::unix::net::SocketAddr::from_abstract_name(name.as_bytes())?)
83        }
84        #[cfg(not(target_os = "linux"))]
85        Bind::Abstract(_) => Err(std::io::Error::other("abstract sockets need Linux")),
86        Bind::Path(path) => UnixStream::connect(path),
87        Bind::Inherited => Err(std::io::Error::other("a client does not inherit the socket")),
88    };
89}
90
91fn read_message(reader: &mut BufReader<UnixStream>) -> Option<ServiceMessage> {
92    let mut line: String = String::new();
93    return match reader.read_line(&mut line) {
94        Ok(read) if read > 0 => decode_service(line.trim_end_matches('\n')).ok(),
95        _ => None,
96    };
97}
98
99/// What:
100///  Connects, waiting for a start the operating system performs, and returns the stream with the service's version.
101/// Why:
102///   The wait is bounded and honest: one notice after the grace, then silence until the bound.
103///
104/// In TS you'd write (pseudocode):
105/// ```ts
106/// const { stream, version } = await reach(config, notices)
107/// ```
108fn reach(
109    config: &ClientConfig,
110    notices: &mut dyn Write,
111    announced: &mut bool,
112    trace: &mut ReachTrace,
113) -> Result<(BufReader<UnixStream>, u32, Option<String>), ClientFailure> {
114    let started: Instant = Instant::now();
115    let mut refused_here: bool = false;
116    loop {
117        trace.tries += 1;
118        match connect(&config.address) {
119            Ok(stream) => {
120                let _timeout = stream.set_read_timeout(Some(config.start_wait));
121                let mut reader: BufReader<UnixStream> = BufReader::new(stream);
122                if let Some(ServiceMessage::Welcome { version, release }) = read_message(&mut reader) {
123                    let _widened = reader.get_ref().set_read_timeout(Some(config.commit_wait));
124                    trace.saw_release(release.as_deref());
125                    if refused_here {
126                        trace.start_waited += started.elapsed();
127                    }
128                    return Ok((reader, version, release));
129                }
130                // Connected, but nothing greeted: the service is still starting or is going away.
131                trace.refused += 1;
132                refused_here = true;
133            }
134            Err(_) => match config.activation {
135                Activation::Unsupported(platform) => return Err(ClientFailure::NoActivation(platform)),
136                Activation::Managed => {
137                    trace.refused += 1;
138                    refused_here = true;
139                }
140            },
141        }
142        if started.elapsed() >= config.start_wait {
143            return Err(ClientFailure::DidNotStart);
144        }
145        if !*announced && started.elapsed() >= START_NOTICE_GRACE {
146            *announced = true;
147            let _written = writeln!(notices, "{STARTING_NOTICE}");
148        }
149        std::thread::sleep(CONNECT_PAUSE);
150    }
151}
152
153/// What:
154///  Reaches a service that speaks this wrapper's protocol and was started from this wrapper's release, asking any other to
155///  retire first.
156/// Why:
157///   The manager keeps its socket across an upgrade or a downgrade, so a service from the other release may answer: one
158///  whose program was since removed, or one that behaves as the other release did. It is asked to finish its batch and
159///  exit, and the next connection makes the manager start the program the rewritten unit names. The asking is bounded by
160///  the start wait from the first ask (the same wait the manager's start gets), because the retiring service still accepts connections until it has exited, and
161///  each connection it accepts in that time is asked again. A service of a newer protocol cannot be asked (it refuses a
162///  message it cannot read as its own), so that ends the command.
163///
164/// In TS you'd write (pseudocode):
165/// ```ts
166/// const reader = await reachCurrent(config, notices, announced)
167/// ```
168fn reach_current(config: &ClientConfig, notices: &mut dyn Write, announced: &mut bool, trace: &mut ReachTrace) -> Result<BufReader<UnixStream>, ClientFailure> {
169    let began: Instant = Instant::now();
170    let reached = reach_current_untimed(config, notices, announced, trace);
171    trace.elapsed += began.elapsed();
172    return reached;
173}
174
175/// `reach_current` without the clock around it.
176fn reach_current_untimed(config: &ClientConfig, notices: &mut dyn Write, announced: &mut bool, trace: &mut ReachTrace) -> Result<BufReader<UnixStream>, ClientFailure> {
177    let mut give_up_at: Option<Instant> = None;
178    loop {
179        let (mut reader, version, release) = reach(config, notices, announced, trace)?;
180        if version == PROTOCOL_VERSION && release.as_deref() == Some(RELEASE) {
181            trace.answered_by = release;
182            return Ok(reader);
183        }
184        if version > PROTOCOL_VERSION {
185            return Err(ClientFailure::NoCommonVersion);
186        }
187        let deadline: Instant = *give_up_at.get_or_insert_with(|| {
188            let _written = writeln!(notices, "{UPDATING_NOTICE}");
189            return Instant::now() + config.start_wait;
190        });
191        if Instant::now() >= deadline {
192            return Err(ClientFailure::OtherRelease);
193        }
194        // Written in the version the service announced: a service of an earlier release reads a retire in no other.
195        let mut retire: String = encode_retire(version);
196        retire.push('\n');
197        let _sent = reader.get_mut().write_all(retire.as_bytes());
198        trace.retires_sent += 1;
199        std::thread::sleep(CONNECT_PAUSE * 4);
200    }
201}
202
203/// The user-facing failure for a code the service gave.
204pub fn failure_for(code: FailureCode, request: &CommitRequest) -> ClientFailure {
205    let repository: String = request.repository.display().to_string();
206    return match code {
207        FailureCode::RepositoryFull => ClientFailure::RepositoryFull(repository),
208        FailureCode::RepositoryReadOnly => ClientFailure::RepositoryReadOnly(repository),
209        FailureCode::IndexInUse => ClientFailure::IndexInUse(String::from("Another program")),
210        FailureCode::Rejected => ClientFailure::Refused,
211        FailureCode::BranchKeepsChanging => ClientFailure::BranchKeepsChanging {
212            branch: request.reference.trim_start_matches("refs/heads/").to_string(),
213            times: super::service_land::LANDING_ATTEMPTS,
214        },
215        FailureCode::BranchMoved => ClientFailure::BranchMoved { branch: request.reference.trim_start_matches("refs/heads/").to_string() },
216        FailureCode::Unavailable => ClientFailure::Unavailable,
217    };
218}
219
220/// What:
221///  Sends a commit request and waits until it is answered.
222/// Why:
223///   The same identity goes with every send, so a service that landed the commit and died before answering reports it
224///  as already landed, and nothing lands twice.
225///
226/// In TS you'd write (pseudocode):
227/// ```ts
228/// await submit(config, request, notices)
229/// ```
230pub fn submit(config: &ClientConfig, request: &CommitRequest, notices: &mut dyn Write) -> Result<Submitted, ClientFailure> {
231    return submit_traced(config, request, notices, &mut SubmitTrace::default());
232}
233
234/// What:
235///  `submit`,
236/// leaving in `trace` how the service was reached and how long the answer took.
237/// Why:
238///   The wrapper's timing record states the tries,
239/// the retires and the releases of the reach,
240/// the queue positions the service
241///  reported and the time between the request being written and the answer;
242/// the trace is filled as the send proceeds,
243/// so a
244///  send that fails still reports what it did.
245///
246/// In TS you'd write (pseudocode):
247/// ```ts
248/// await submitTraced(config, request, notices, trace)
249/// ```
250pub fn submit_traced(config: &ClientConfig, request: &CommitRequest, notices: &mut dyn Write, trace: &mut SubmitTrace) -> Result<Submitted, ClientFailure> {
251    let began: Instant = Instant::now();
252    let answered = submit_loop(config, request, notices, trace);
253    trace.total += began.elapsed();
254    return answered;
255}
256
257/// The sends and the waits of `submit_traced`.
258fn submit_loop(config: &ClientConfig, request: &CommitRequest, notices: &mut dyn Write, trace: &mut SubmitTrace) -> Result<Submitted, ClientFailure> {
259    let line: String = encode_client(&ClientMessage::Commit(Box::new(request.clone())));
260    let waiting_since: Instant = Instant::now();
261    let mut announced: bool = false;
262    let mut breaks: usize = 0;
263    let mut last_position: Option<usize> = None;
264    let mut holder: Option<String> = None;
265    loop {
266        if waiting_since.elapsed() >= config.commit_wait {
267            return Err(ClientFailure::WaitedTooLong { minutes: config.commit_wait.as_secs() / 60 });
268        }
269        let mut reader: BufReader<UnixStream> = reach_current(config, notices, &mut announced, &mut trace.reach)?;
270        let mut payload: String = line.clone();
271        payload.push('\n');
272        if reader.get_mut().write_all(payload.as_bytes()).is_err() {
273            breaks += 1;
274            trace.reconnects += 1;
275            if breaks > RECONNECTS {
276                return Err(ClientFailure::MayNotHaveLanded);
277            }
278            continue;
279        }
280        trace.sends += 1;
281        let written_at: Instant = Instant::now();
282        let answered: Option<Result<Submitted, ClientFailure>> = loop {
283            match read_message(&mut reader) {
284                Some(ServiceMessage::Position { number, of }) => {
285                    if last_position != Some(number) {
286                        last_position = Some(number);
287                        trace.positions.push(number);
288                        let _written = writeln!(notices, "{}", position_notice(number, of));
289                    }
290                }
291                Some(ServiceMessage::Landed { commit, already, .. }) => break Some(Ok(Submitted::Landed { commit, already })),
292                Some(ServiceMessage::RetryPrepare { base, landed, .. }) => break Some(Ok(Submitted::PrepareAgain { base, landed })),
293                Some(ServiceMessage::Failed { code: FailureCode::BranchMoved, .. }) => break Some(Ok(Submitted::Moved)),
294                Some(ServiceMessage::Failed { code: FailureCode::IndexInUse, .. }) => {
295                    break Some(Err(ClientFailure::IndexInUse(holder.take().unwrap_or_else(|| { return String::from("Another program"); }))));
296                }
297                Some(ServiceMessage::Failed { code, .. }) => break Some(Err(failure_for(code, request))),
298                Some(ServiceMessage::Waiting { text }) => {
299                    trace.waiting_notices += 1;
300                    let _written = write!(notices, "{text}");
301                }
302                Some(ServiceMessage::Holder { who }) => holder = Some(who),
303                Some(ServiceMessage::Welcome { .. }) => {}
304                None => break None,
305            }
306        };
307        trace.answer_wait += written_at.elapsed();
308        if let Some(done) = answered {
309            return done;
310        }
311        // The connection broke before an answer: the service died or was replaced. Send the same request again.
312        breaks += 1;
313        if breaks > RECONNECTS {
314            return Err(ClientFailure::MayNotHaveLanded);
315        }
316        std::thread::sleep(CONNECT_PAUSE * u32::try_from(breaks).unwrap_or(1));
317    }
318}
319
320/// What:
321///  Asks the service listening at `socket` to finish its batch and exit, when one answers; nothing else is waited for.
322/// Why:
323///   The single service of the earlier releases is replaced by one service per worktree. It is asked politely first, in the
324///  protocol version it announced, so that a batch it is landing is finished; the manager then stops its units. A socket
325///  nobody listens on, or a service that does not greet, is no one to ask.
326///
327/// In TS you'd write (pseudocode):
328/// ```ts
329/// await askToRetire(socket)
330/// ```
331pub fn ask_to_retire(socket: &std::path::Path) {
332    let Ok(stream) = UnixStream::connect(socket) else { return };
333    let _timeout = stream.set_read_timeout(Some(START_WAIT));
334    let mut reader: BufReader<UnixStream> = BufReader::new(stream);
335    let Some(ServiceMessage::Welcome { version, .. }) = read_message(&mut reader) else { return };
336    let mut retire: String = encode_retire(version);
337    retire.push('\n');
338    let _sent = reader.get_mut().write_all(retire.as_bytes());
339}
340
341/// A scratch directory handed to the service: the service removes it when this value is dropped, and when the process that
342///  holds it dies by any means, because the connection is the only thing that says the directory is still wanted.
343pub struct ScratchWatch {
344    /// The open connection. Close-on-exec, so no program the wrapper starts keeps it open after the wrapper is gone.
345    _connection: UnixStream,
346}
347
348/// What:
349///  Hands the commit's scratch directory to the service, which removes it when the connection closes.
350/// Why:
351///   A client killed without warning cannot remove what it made, and no one can tell afterwards whether it is gone or
352///  stopped: the open connection is the signal, without a process id and without a lock. The announcement is written at once
353///  and the greeting is not waited for, so a service that is frozen or still starting takes it when it reads, and a client
354///  killed meanwhile still loses its directory. A service that is not running takes nothing, and a service of another release
355///  refuses a message it does not know and closes nothing of the client's; in both the client removes the directory itself
356///  when it ends, as it always has.
357///
358/// In TS you'd write (pseudocode):
359/// ```ts
360/// const watch = watchScratch(config, gitDir, path) // undefined when nothing listens
361/// ```
362pub fn watch_scratch(config: &ClientConfig, git_dir: &std::path::Path, path: &std::path::Path) -> Option<ScratchWatch> {
363    let mut connection: UnixStream = connect(&config.address).ok()?;
364    let mut line: String = encode_client(&ClientMessage::Scratch { git_dir: git_dir.to_path_buf(), path: path.to_path_buf() });
365    line.push('\n');
366    connection.write_all(line.as_bytes()).ok()?;
367    return Some(ScratchWatch { _connection: connection });
368}
369
370/// What:
371///  Tells the service that work items were written for a repository, starting it through the operating system when it
372///  is not running, and returns once the service has taken the message.
373/// Why:
374///   The wrapper never starts the service or runs the work itself; the message is the only thing the service needs to
375///  look at the repository's work directory. The connection closes when the service has read the line, which is the
376///  confirmation that it will act.
377///
378/// In TS you'd write (pseudocode):
379/// ```ts
380/// await wake(config, gitDir, reviewKey)
381/// ```
382pub fn wake(config: &ClientConfig, git_dir: &std::path::Path, review_key: Option<&ReviewKey>, notices: &mut dyn Write) -> Result<(), ClientFailure> {
383    let mut line: String = encode_client(&ClientMessage::Work { git_dir: git_dir.to_path_buf(), review_key: review_key.cloned() });
384    line.push('\n');
385    let mut announced: bool = false;
386    let mut reader: BufReader<UnixStream> = reach_current(config, notices, &mut announced, &mut ReachTrace::default())?;
387    if reader.get_mut().write_all(line.as_bytes()).is_err() {
388        return Err(ClientFailure::Unavailable);
389    }
390    // The service closes the connection after it has read the line.
391    while read_message(&mut reader).is_some() {}
392    return Ok(());
393}
394
395/// The client's bounds and how it reads the service's lines.
396#[cfg(test)]
397#[path = "service_client_tests.rs"]
398mod tests;