git_policy_cli/service/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 crate::commit_timing::{ReachTrace, SubmitTrace, holder_kind_of};
17use crate::service_messages::{ClientFailure, Platform, SILENT_NOTICE, STARTING_NOTICE, UPDATING_NOTICE, position_notice};
18use crate::service_protocol::{
19 ClientMessage, CommitRequest, FailureCode, LandedCapture, PROTOCOL_VERSION, RELEASE, ServiceMessage, decode_service, encode_client, encode_retire,
20};
21use crate::service_instance::service_unit_instance;
22use crate::service_provision::ProvisionHost;
23use crate::service_review_key::ReviewKey;
24use crate::systemd_dbus::{Bus, EXCHANGE_WAIT};
25use crate::service_server::Bind;
26use std::io::{BufRead, BufReader, Write};
27use std::os::unix::net::UnixStream;
28use std::time::{Duration, Instant};
29
30/// How long the client waits for a service to start or to retire when it cannot ask the service manager how the start is going.
31/// Ten times the slowest of 52 cold starts in this repository's commit timing log (`startWaitMs`, longest 1041 ms,
32/// median 47 ms, 2026-11-03); the 70 commits that also asked an older service to retire all reached the service within
33/// that same 1041 ms (`reachMs`). A client that can ask the manager has no such bound: it ends when the manager says the
34/// start failed.
35pub const START_WAIT: Duration = Duration::from_secs(10);
36
37/// How long a start takes before the client says it is starting the service.
38pub const START_NOTICE_GRACE: Duration = Duration::from_secs(1);
39
40/// How many times a request is sent again after the connection broke before the client says it cannot be sure.
41pub const RECONNECTS: usize = 5;
42
43/// The pause between connection tries while the service starts.
44const CONNECT_PAUSE: Duration = Duration::from_millis(25);
45
46/// What the client may rely on the operating system to do.
47#[derive(Clone, Copy, Debug, Eq, PartialEq)]
48pub enum Activation {
49 /// The manager owns the socket and starts the service on demand.
50 Managed,
51 /// This platform cannot start the service on demand.
52 Unsupported(Platform),
53}
54
55/// What the client needs to reach the service.
56#[derive(Clone, Debug)]
57pub struct ClientConfig {
58 /// Where the service listens.
59 pub address: Bind,
60 /// What the operating system can do about a service that is not running.
61 pub activation: Activation,
62 /// Longest wait for a start when `start_watch` is absent; with a watch the wait ends only when the manager says the start
63 /// failed, and this is the wait for an older service to retire.
64 pub start_wait: Duration,
65 /// How long the service may send nothing, heartbeats included, before the client treats it as stopped. There is no limit
66 /// on how long a request waits while the service keeps sending.
67 pub heartbeat_silence: Duration,
68 /// How to ask the service manager whether the service failed to start; absent where the client cannot ask.
69 pub start_watch: Option<ManagerWatch>,
70 /// A scratch directory of this command to keep from being swept as stale while the command waits.
71 pub keep_fresh: Option<FreshScratch>,
72 /// The longest the client waits for the service's answer; then it withdraws the request (`wait_ceilings.rs`).
73 pub answer_ceiling: Duration,
74 /// The longest the client waits for the service to answer the withdrawal.
75 pub withdraw_ceiling: Duration,
76 /// How much longer a client whose own request is inside the service's branch write waits, after its answer ceiling, for that
77 /// write to end before it reports that the result is not known (`wait_ceilings.rs`, `SHORT_EXTRA_WAIT`).
78 pub extra_wait: Duration,
79 /// The longest a start is waited for when the service manager can be asked.
80 pub start_ceiling: Duration,
81}
82
83/// What:
84/// A scratch directory a waiting command keeps fresh, and how often.
85/// Why:
86/// The scratch sweep removes entries by age, and a wait with no bound can outlast any age; a waiter that is alive says so by
87/// touching its directory, one that died stops and the sweep collects the directory.
88///
89/// In TS you'd write (pseudocode):
90/// ```ts
91/// type FreshScratch = { path: string; every: number };
92/// ```
93#[derive(Clone, Debug)]
94pub struct FreshScratch {
95 /// The directory.
96 pub path: std::path::PathBuf,
97 /// How often its modification time is renewed while the command waits.
98 pub every: Duration,
99}
100
101/// What:
102/// How the client asks the service manager whether the service unit has failed.
103/// Why:
104/// A service that is starting sends nothing, so the wait for its first greeting has no heartbeat to listen for. The manager
105/// knows whether the start failed, which is the one thing that ends that wait.
106///
107/// In TS you'd write (pseudocode):
108/// ```ts
109/// type ManagerWatch = { bus: string; unit: string; every: number; state: (bus, unit) => string | undefined; start: (bus, unit) => boolean };
110/// ```
111#[derive(Clone, Debug)]
112pub struct ManagerWatch {
113 /// The session bus of the account.
114 pub bus: std::path::PathBuf,
115 /// The service unit of the worktree.
116 pub unit: String,
117 /// How often the manager is asked while the greeting has not come.
118 pub every: Duration,
119 /// The unit's `ActiveState` now (`active`, `activating`, `inactive`, `failed`, ...); nothing when the manager cannot be
120 /// asked, which leaves the wait to the start wait.
121 pub state: fn(&std::path::Path, &str) -> Option<String>,
122 /// Asks the manager to start the unit; whether the manager took the request.
123 pub start: fn(&std::path::Path, &str) -> bool,
124}
125
126/// How often the service manager is asked whether the service unit failed, while a start is waited for.
127pub const MANAGER_ASK_EVERY: Duration = START_NOTICE_GRACE;
128
129/// The manager's `ActiveState` of `unit`: nothing when the bus or the unit cannot be asked about.
130fn unit_state(bus: &std::path::Path, unit: &str) -> Option<String> {
131 let mut connection: Bus = Bus::connect(bus, EXCHANGE_WAIT).ok()?;
132 return connection.active_state(unit).ok()?;
133}
134
135/// Asks the manager to start `unit`; whether the request was taken.
136fn start_unit(bus: &std::path::Path, unit: &str) -> bool {
137 let Ok(mut connection) = Bus::connect(bus, EXCHANGE_WAIT) else { return false };
138 return connection.start_unit(unit).is_ok();
139}
140
141/// What:
142/// The watch that asks the account's service manager about the worktree's service unit.
143/// Why:
144/// The route knows the session bus and the worktree's instance, which is all the unit's name needs.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// const watch = managerWatchFor(host)
149/// ```
150pub fn manager_watch_for(host: &ProvisionHost) -> ManagerWatch {
151 return ManagerWatch { bus: host.bus.clone(), unit: service_unit_instance(host.instance.as_str()), every: MANAGER_ASK_EVERY, state: unit_state, start: start_unit };
152}
153
154/// What the client heard from the service in one read.
155enum Heard {
156 /// A message.
157 Message(ServiceMessage),
158 /// The service closed the connection, or sent something that is not a message.
159 Closed,
160 /// Nothing came within the silence bound.
161 Silent,
162}
163
164/// What a request came to.
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub enum Submitted {
167 /// The commit is on the branch.
168 Landed { commit: String, already: bool },
169 /// A named path changed on the branch: prepare again from this base and submit again with the same identity.
170 /// `landed` tells when the bytes of the commits the service landed since were captured, for the ones it knows.
171 PrepareAgain { base: String, landed: Vec<LandedCapture> },
172 /// 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).
173 /// The caller decides whether to build it again on the new tip or to stop.
174 Moved,
175}
176
177fn connect(address: &Bind) -> std::io::Result<UnixStream> {
178 return match address {
179 #[cfg(target_os = "linux")]
180 Bind::Abstract(name) => {
181 use std::os::linux::net::SocketAddrExt;
182 UnixStream::connect_addr(&std::os::unix::net::SocketAddr::from_abstract_name(name.as_bytes())?)
183 }
184 #[cfg(not(target_os = "linux"))]
185 Bind::Abstract(_) => Err(std::io::Error::other("abstract sockets need Linux")),
186 Bind::Path(path) => UnixStream::connect(path),
187 Bind::Inherited => Err(std::io::Error::other("a client does not inherit the socket")),
188 };
189}
190
191fn read_message(reader: &mut BufReader<UnixStream>) -> Option<ServiceMessage> {
192 return match listen(reader) {
193 Heard::Message(message) => Some(message),
194 Heard::Closed | Heard::Silent => None,
195 };
196}
197
198/// What:
199/// Reads one line from the service and says whether it was a message, a close, or silence.
200/// Why:
201/// A read that times out is the service saying nothing for the silence bound, which is not the same as a service that
202/// closed its end: the first is asked again with the same request, the second is a service that went away.
203///
204/// In TS you'd write (pseudocode):
205/// ```ts
206/// const heard = await listen(reader) // { message } | 'closed' | 'silent'
207/// ```
208fn listen(reader: &mut BufReader<UnixStream>) -> Heard {
209 let mut line: String = String::new();
210 return match reader.read_line(&mut line) {
211 Ok(read) if read > 0 => decode_service(line.trim_end_matches('\n')).map_or(Heard::Closed, Heard::Message),
212 Err(error) if matches!(error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => Heard::Silent,
213 _ => Heard::Closed,
214 };
215}
216
217/// What:
218/// Connects, waiting for a start the operating system performs, and returns the stream with the service's version.
219/// Why:
220/// With a manager to ask, the wait has no clock: it ends when the service greets, or when the manager has said twice in a
221/// row that the unit failed. Where the manager cannot be asked it ends at the start wait, as it always did. Once a service
222/// has greeted during this command (`service_seen`), a service that then greets nothing within the silence bound is the
223/// one that stopped, and that ends the wait.
224///
225/// In TS you'd write (pseudocode):
226/// ```ts
227/// const { stream, version } = await reach(config, notices, serviceSeen)
228/// ```
229fn reach(
230 config: &ClientConfig,
231 notices: &mut dyn Write,
232 announced: &mut bool,
233 trace: &mut ReachTrace,
234 service_seen: bool,
235) -> Result<(BufReader<UnixStream>, u32, Option<String>), ClientFailure> {
236 let started: Instant = Instant::now();
237 let mut refused_here: bool = false;
238 let mut last_asked: Instant = started;
239 let mut failed_answers: u32 = 0;
240 let mut cannot_ask: bool = false;
241 let mut last_start_request: Option<Instant> = None;
242 loop {
243 trace.tries += 1;
244 match connect(&config.address) {
245 Ok(stream) => {
246 let patience: Duration = match (&config.start_watch, service_seen) {
247 (_, true) => config.heartbeat_silence,
248 (Some(watch), false) => watch.every,
249 (None, false) => config.start_wait,
250 };
251 let _timeout = stream.set_read_timeout(Some(patience));
252 let mut reader: BufReader<UnixStream> = BufReader::new(stream);
253 match listen(&mut reader) {
254 Heard::Message(ServiceMessage::Welcome { version, release }) => {
255 let _widened = reader.get_ref().set_read_timeout(Some(config.heartbeat_silence));
256 trace.saw_release(release.as_deref());
257 if refused_here {
258 trace.start_waited += started.elapsed();
259 }
260 return Ok((reader, version, release));
261 }
262 // A service that greeted before and now greets nothing is the one that stopped.
263 Heard::Silent if service_seen => return Err(ClientFailure::ServiceSilent),
264 // Connected, but nothing greeted: the service is still starting or is going away.
265 Heard::Message(_) | Heard::Closed | Heard::Silent => {
266 trace.refused += 1;
267 refused_here = true;
268 }
269 }
270 }
271 Err(_) => match config.activation {
272 Activation::Unsupported(platform) => return Err(ClientFailure::NoActivation(platform)),
273 Activation::Managed => {
274 trace.refused += 1;
275 refused_here = true;
276 }
277 },
278 }
279 match &config.start_watch {
280 Some(watch) => {
281 if last_asked.elapsed() >= watch.every {
282 last_asked = Instant::now();
283 let answer: Option<String> = (watch.state)(watch.bus.as_path(), watch.unit.as_str());
284 // A unit still `inactive` after the start wait, with the client's connection waiting on its socket, is one the
285 // manager did not start; it is asked to, again after each further start wait, and the client goes on waiting.
286 // A unit that is `activating` is being started and is left alone.
287 if answer.as_deref() == Some("inactive")
288 && started.elapsed() >= config.start_wait
289 && last_start_request.is_none_or(|at| { return at.elapsed() >= config.start_wait; })
290 {
291 let _taken = (watch.start)(watch.bus.as_path(), watch.unit.as_str());
292 last_start_request = Some(Instant::now());
293 }
294 cannot_ask = answer.is_none();
295 failed_answers = if answer.as_deref() == Some("failed") { failed_answers + 1 } else { 0 };
296 }
297 // One answer of "failed" can be the unit's last run, before the connection made the manager start it again. A start the
298 // manager reports as under way is waited for until the start ceiling and no longer (owner, 2026-11-03: "nothing
299 // should ever wait forever or indefinitely").
300 if failed_answers >= 2 || (cannot_ask && started.elapsed() >= config.start_wait) || started.elapsed() >= config.start_ceiling {
301 return Err(ClientFailure::DidNotStart);
302 }
303 }
304 None => {
305 if started.elapsed() >= config.start_wait {
306 return Err(ClientFailure::DidNotStart);
307 }
308 }
309 }
310 if !*announced && started.elapsed() >= START_NOTICE_GRACE {
311 *announced = true;
312 let _written = writeln!(notices, "{STARTING_NOTICE}");
313 }
314 std::thread::sleep(CONNECT_PAUSE);
315 }
316}
317
318/// What:
319/// Reaches a service that speaks this wrapper's protocol and was started from this wrapper's release, asking any other to
320/// retire first.
321/// Why:
322/// The manager keeps its socket across an upgrade or a downgrade, so a service from the other release may answer: one
323/// whose program was since removed, or one that behaves as the other release did. It is asked to finish its batch and
324/// exit, and the next connection makes the manager start the program the rewritten unit names. The asking is bounded by
325/// 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
326/// each connection it accepts in that time is asked again. A service of a newer protocol cannot be asked (it refuses a
327/// message it cannot read as its own), so that ends the command.
328///
329/// In TS you'd write (pseudocode):
330/// ```ts
331/// const reader = await reachCurrent(config, notices, announced)
332/// ```
333fn reach_current(config: &ClientConfig, notices: &mut dyn Write, announced: &mut bool, trace: &mut ReachTrace, service_seen: bool) -> Result<BufReader<UnixStream>, ClientFailure> {
334 let began: Instant = Instant::now();
335 let reached = reach_current_untimed(config, notices, announced, trace, service_seen);
336 trace.elapsed += began.elapsed();
337 return reached;
338}
339
340/// `reach_current` without the clock around it.
341fn reach_current_untimed(config: &ClientConfig, notices: &mut dyn Write, announced: &mut bool, trace: &mut ReachTrace, service_seen: bool) -> Result<BufReader<UnixStream>, ClientFailure> {
342 let mut give_up_at: Option<Instant> = None;
343 loop {
344 let (mut reader, version, release) = reach(config, notices, announced, trace, service_seen)?;
345 if version == PROTOCOL_VERSION && release.as_deref() == Some(RELEASE) {
346 trace.answered_by = release;
347 return Ok(reader);
348 }
349 if version > PROTOCOL_VERSION {
350 return Err(ClientFailure::NoCommonVersion);
351 }
352 let deadline: Instant = *give_up_at.get_or_insert_with(|| {
353 let _written = writeln!(notices, "{UPDATING_NOTICE}");
354 return Instant::now() + config.start_wait;
355 });
356 if Instant::now() >= deadline {
357 return Err(ClientFailure::OtherRelease);
358 }
359 // Written in the version the service announced: a service of an earlier release reads a retire in no other.
360 let mut retire: String = encode_retire(version);
361 retire.push('\n');
362 let _sent = reader.get_mut().write_all(retire.as_bytes());
363 trace.retires_sent += 1;
364 std::thread::sleep(CONNECT_PAUSE * 4);
365 }
366}
367
368/// The user-facing failure for a code the service gave.
369pub fn failure_for(code: FailureCode, request: &CommitRequest) -> ClientFailure {
370 let repository: String = request.repository.display().to_string();
371 return match code {
372 FailureCode::RepositoryFull => ClientFailure::RepositoryFull(repository),
373 FailureCode::RepositoryReadOnly => ClientFailure::RepositoryReadOnly(repository),
374 FailureCode::IndexInUse => ClientFailure::IndexInUse(String::from("Another program")),
375 FailureCode::Rejected => ClientFailure::Refused,
376 FailureCode::BranchKeepsChanging => ClientFailure::BranchKeepsChanging {
377 branch: request.reference.trim_start_matches("refs/heads/").to_string(),
378 times: crate::service_land::LANDING_ATTEMPTS,
379 },
380 FailureCode::BranchMoved => ClientFailure::BranchMoved { branch: request.reference.trim_start_matches("refs/heads/").to_string() },
381 FailureCode::StepTooSlow => ClientFailure::StepTooSlow,
382 FailureCode::Unavailable => ClientFailure::Unavailable,
383 };
384}
385
386/// What:
387/// Sends a commit request and waits until it is answered.
388/// Why:
389/// The same identity goes with every send, so a service that landed the commit and died before answering reports it
390/// as already landed, and nothing lands twice.
391///
392/// In TS you'd write (pseudocode):
393/// ```ts
394/// await submit(config, request, notices)
395/// ```
396pub fn submit(config: &ClientConfig, request: &CommitRequest, notices: &mut dyn Write) -> Result<Submitted, ClientFailure> {
397 return submit_traced(config, request, notices, &mut SubmitTrace::default());
398}
399
400/// What:
401/// `submit`,
402/// leaving in `trace` how the service was reached and how long the answer took.
403/// Why:
404/// The wrapper's timing record states the tries,
405/// the retires and the releases of the reach,
406/// the queue positions the service
407/// reported and the time between the request being written and the answer;
408/// the trace is filled as the send proceeds,
409/// so a
410/// send that fails still reports what it did.
411///
412/// In TS you'd write (pseudocode):
413/// ```ts
414/// await submitTraced(config, request, notices, trace)
415/// ```
416pub fn submit_traced(config: &ClientConfig, request: &CommitRequest, notices: &mut dyn Write, trace: &mut SubmitTrace) -> Result<Submitted, ClientFailure> {
417 let began: Instant = Instant::now();
418 let answered = submit_loop(config, request, notices, trace);
419 trace.total += began.elapsed();
420 return answered;
421}
422
423/// The sends and the waits of `submit_traced`.
424///
425/// The wait for the answer has no clock: it lasts as long as the service keeps sending, and ends when the service goes
426/// silent for the silence bound. The request is then sent again with the same identity, so nothing lands twice, and the
427/// command ends only when every try has met silence or a closed connection.
428fn submit_loop(config: &ClientConfig, request: &CommitRequest, notices: &mut dyn Write, trace: &mut SubmitTrace) -> Result<Submitted, ClientFailure> {
429 let line: String = encode_client(&ClientMessage::Commit(Box::new(request.clone())));
430 let mut announced: bool = false;
431 let mut breaks: usize = 0;
432 let mut last_position: Option<usize> = None;
433 let mut holder: Option<String> = None;
434 let mut service_seen: bool = false;
435 let mut last_break_was_silence: bool = false;
436 // The scratch directory of a command that waits is renewed at once and then as often as the config says, whatever the
437 // service sends, so a wait longer than the sweep's age never has its directory swept.
438 let waiting_since: Instant = Instant::now();
439 let mut last_blocker: Option<String> = None;
440 // Whether the service said this request is inside its stuck branch write, and when the extra wait for that write ends.
441 let mut inside_write: bool = false;
442 // The wait for the answer ends here, and the extra wait for a write the request is inside ends the extra wait after it: both
443 // are measured from the start of the wait, not from the moment a loop first notices them.
444 let answer_deadline: Instant = waiting_since + config.answer_ceiling;
445 let mut last_renewed: Option<Instant> = None;
446 let mut renew_scratch = |settings: &ClientConfig| {
447 if let Some(fresh) = settings.keep_fresh.as_ref()
448 && last_renewed.is_none_or(|at| { return at.elapsed() >= fresh.every; })
449 {
450 crate::scratch_location::renew(fresh.path.as_path());
451 last_renewed = Some(Instant::now());
452 }
453 };
454 loop {
455 let mut reader: BufReader<UnixStream> = reach_current(config, notices, &mut announced, &mut trace.reach, service_seen)?;
456 service_seen = true;
457 let mut payload: String = line.clone();
458 payload.push('\n');
459 if reader.get_mut().write_all(payload.as_bytes()).is_err() {
460 breaks += 1;
461 trace.reconnects += 1;
462 if breaks > RECONNECTS {
463 return Err(ClientFailure::MayNotHaveLanded);
464 }
465 continue;
466 }
467 trace.sends += 1;
468 renew_scratch(config);
469 let written_at: Instant = Instant::now();
470 let answered: Option<Result<Submitted, ClientFailure>> = loop {
471 let heard: Heard = listen(&mut reader);
472 if matches!(heard, Heard::Message(_)) {
473 renew_scratch(config);
474 }
475 // A notice that arrives with the ceiling counts, so it is read before the ceiling is judged.
476 if matches!(heard, Heard::Message(ServiceMessage::InsideWrite { .. })) {
477 inside_write = true;
478 }
479 // The wait ends at the ceiling whatever the service sends: the request is withdrawn, and the answer to that is
480 // the command's end (landed after all, or the error).
481 // A request the service says is inside its branch write is not withdrawn: its commit may be on the branch already, so the
482 // client waits the short extra wait for that write (owner, 2026-11-04, "Short extra wait"), listening for the landing,
483 // and then reports that the result is not known; the write is left running.
484 if waiting_since.elapsed() >= config.answer_ceiling && !matches!(heard, Heard::Message(ServiceMessage::Landed { .. } | ServiceMessage::RetryPrepare { .. } | ServiceMessage::Failed { .. })) {
485 if !inside_write {
486 trace.answer_wait += written_at.elapsed();
487 return withdraw(config, request.id.as_str(), last_blocker.take(), answer_deadline);
488 }
489 if Instant::now() >= answer_deadline + config.extra_wait {
490 trace.answer_wait += written_at.elapsed();
491 return Err(ClientFailure::WithdrawalUnconfirmed { blocker: last_blocker.take() });
492 }
493 }
494 match heard {
495 Heard::Message(ServiceMessage::Position { number, of }) => {
496 if last_position != Some(number) {
497 last_position = Some(number);
498 trace.positions.push(number);
499 let _written = writeln!(notices, "{}", position_notice(number, of));
500 }
501 }
502 Heard::Message(ServiceMessage::Landed { commit, already, .. }) => break Some(Ok(Submitted::Landed { commit, already })),
503 Heard::Message(ServiceMessage::RetryPrepare { base, landed, .. }) => break Some(Ok(Submitted::PrepareAgain { base, landed })),
504 Heard::Message(ServiceMessage::Failed { code: FailureCode::BranchMoved, .. }) => break Some(Ok(Submitted::Moved)),
505 Heard::Message(ServiceMessage::Failed { code: FailureCode::IndexInUse, .. }) => {
506 break Some(Err(ClientFailure::IndexInUse(holder.take().unwrap_or_else(|| { return String::from("Another program"); }))));
507 }
508 Heard::Message(ServiceMessage::Failed { code, .. }) => break Some(Err(failure_for(code, request))),
509 Heard::Message(ServiceMessage::Waiting { text } | ServiceMessage::InsideWrite { text }) => {
510 last_blocker = Some(text.trim().to_string());
511 trace.waiting_notices += 1;
512 trace.holder_kind = Some(holder_kind_of(text.as_str()));
513 let _written = write!(notices, "{text}");
514 }
515 Heard::Message(ServiceMessage::Holder { who }) => holder = Some(who),
516 // Each message, this one too, restarts the silence bound; nothing else is needed.
517 Heard::Message(ServiceMessage::Welcome { .. } | ServiceMessage::Heartbeat | ServiceMessage::Withdrawn { .. }) => {}
518 Heard::Silent => {
519 last_break_was_silence = true;
520 break None;
521 }
522 Heard::Closed => {
523 last_break_was_silence = false;
524 break None;
525 }
526 }
527 };
528 trace.answer_wait += written_at.elapsed();
529 if let Some(done) = answered {
530 return done;
531 }
532 // The service went silent or the connection broke before an answer: the service died, hung or was replaced. Send the
533 // same request again.
534 breaks += 1;
535 if breaks > RECONNECTS {
536 return Err(if last_break_was_silence { ClientFailure::ServiceSilent } else { ClientFailure::MayNotHaveLanded });
537 }
538 if last_break_was_silence {
539 let _written = writeln!(notices, "{SILENT_NOTICE}");
540 }
541 std::thread::sleep(CONNECT_PAUSE * u32::try_from(breaks).unwrap_or(1));
542 }
543}
544
545/// What:
546/// Withdraws the request after the answer ceiling: asks the service, on a connection of its own, to drop the request of this
547/// identity, and turns its answer into the command's end.
548/// Why:
549/// The owner's answer is that a wait that reaches its bound returns an error and the commit never lands later by itself. The
550/// service takes the withdrawal between two landings, so either the commit landed first (the answer is `Landed`, and the command
551/// succeeds) or it never will (`Withdrawn`). A service that does not answer within the withdrawal ceiling leaves it unknown.
552///
553/// In TS you'd write (pseudocode):
554/// ```ts
555/// const answer = await askToWithdraw(config, id); // 'landed' | 'withdrawn' | undefined
556/// ```
557fn withdraw(config: &ClientConfig, id: &str, heard_blocker: Option<String>, answer_deadline: Instant) -> Result<Submitted, ClientFailure> {
558 let minutes: u64 = config.answer_ceiling.as_secs() / 60;
559 // What the service says it is stuck in on this connection replaces what it said on the first one: it is the newer.
560 let mut blocker: Option<String> = heard_blocker;
561 let mut announced: bool = true;
562 let Ok(mut reader) = reach_current(config, &mut std::io::sink(), &mut announced, &mut ReachTrace::default(), true) else {
563 return Err(ClientFailure::WithdrawalUnconfirmed { blocker });
564 };
565 let mut asking: String = encode_client(&ClientMessage::Withdraw { id: id.to_string() });
566 asking.push('\n');
567 if reader.get_mut().write_all(asking.as_bytes()).is_err() {
568 return Err(ClientFailure::WithdrawalUnconfirmed { blocker });
569 }
570 let mut deadline: Instant = Instant::now() + config.withdraw_ceiling;
571 while Instant::now() < deadline {
572 // No read waits past the withdrawal's ceiling, whatever the silence bound says.
573 let _timeout = reader.get_ref().set_read_timeout(Some(deadline.saturating_duration_since(Instant::now()).max(Duration::from_millis(1)).min(config.heartbeat_silence)));
574 match listen(&mut reader) {
575 Heard::Message(ServiceMessage::Landed { commit, .. }) => return Ok(Submitted::Landed { commit, already: true }),
576 Heard::Message(ServiceMessage::Withdrawn { .. }) => return Err(ClientFailure::StillWithService { minutes, blocker }),
577 Heard::Message(ServiceMessage::Waiting { text }) => blocker = Some(text.trim().to_string()),
578 // The service says the request is inside its stuck write: the withdrawal cannot be answered before the write returns,
579 // and the commit may be on the branch, so the wait ends at the same moment it would have ended had the notice come before
580 // the ceiling (owner, 2026-11-04, "Short extra wait").
581 Heard::Message(ServiceMessage::InsideWrite { text }) => {
582 blocker = Some(text.trim().to_string());
583 deadline = deadline.min(answer_deadline + config.extra_wait);
584 }
585 Heard::Message(_) => {}
586 Heard::Silent | Heard::Closed => return Err(ClientFailure::WithdrawalUnconfirmed { blocker }),
587 }
588 }
589 return Err(ClientFailure::WithdrawalUnconfirmed { blocker });
590}
591
592/// What:
593/// Asks the service listening at `socket` to finish its batch and exit, when one answers; nothing else is waited for.
594/// Why:
595/// The single service of the earlier releases is replaced by one service per worktree. It is asked politely first, in the
596/// protocol version it announced, so that a batch it is landing is finished; the manager then stops its units. A socket
597/// nobody listens on, or a service that does not greet, is no one to ask.
598///
599/// In TS you'd write (pseudocode):
600/// ```ts
601/// await askToRetire(socket)
602/// ```
603pub fn ask_to_retire(socket: &std::path::Path) {
604 let Ok(stream) = UnixStream::connect(socket) else { return };
605 let _timeout = stream.set_read_timeout(Some(START_WAIT));
606 let mut reader: BufReader<UnixStream> = BufReader::new(stream);
607 let Some(ServiceMessage::Welcome { version, .. }) = read_message(&mut reader) else { return };
608 let mut retire: String = encode_retire(version);
609 retire.push('\n');
610 let _sent = reader.get_mut().write_all(retire.as_bytes());
611}
612
613/// A scratch directory handed to the service: the service removes it when this value is dropped, and when the process that
614/// holds it dies by any means, because the connection is the only thing that says the directory is still wanted.
615pub struct ScratchWatch {
616 /// The open connection. Close-on-exec, so no program the wrapper starts keeps it open after the wrapper is gone.
617 _connection: UnixStream,
618}
619
620/// What:
621/// Hands the commit's scratch directory to the service, which removes it when the connection closes.
622/// Why:
623/// A client killed without warning cannot remove what it made, and no one can tell afterwards whether it is gone or
624/// stopped: the open connection is the signal, without a process id and without a lock. The announcement is written at once
625/// and the greeting is not waited for, so a service that is frozen or still starting takes it when it reads, and a client
626/// killed meanwhile still loses its directory. A service that is not running takes nothing, and a service of another release
627/// refuses a message it does not know and closes nothing of the client's; in both the client removes the directory itself
628/// when it ends, as it always has.
629///
630/// In TS you'd write (pseudocode):
631/// ```ts
632/// const watch = watchScratch(config, gitDir, path) // undefined when nothing listens
633/// ```
634pub fn watch_scratch(config: &ClientConfig, git_dir: &std::path::Path, path: &std::path::Path) -> Option<ScratchWatch> {
635 let mut connection: UnixStream = connect(&config.address).ok()?;
636 let mut line: String = encode_client(&ClientMessage::Scratch { git_dir: git_dir.to_path_buf(), path: path.to_path_buf() });
637 line.push('\n');
638 connection.write_all(line.as_bytes()).ok()?;
639 return Some(ScratchWatch { _connection: connection });
640}
641
642/// What:
643/// Tells the service that work items were written for a repository, starting it through the operating system when it
644/// is not running, and returns once the service has taken the message.
645/// Why:
646/// The wrapper never starts the service or runs the work itself; the message is the only thing the service needs to
647/// look at the repository's work directory. The connection closes when the service has read the line, which is the
648/// confirmation that it will act.
649///
650/// In TS you'd write (pseudocode):
651/// ```ts
652/// await wake(config, gitDir, reviewKey)
653/// ```
654pub fn wake(config: &ClientConfig, git_dir: &std::path::Path, review_key: Option<&ReviewKey>, notices: &mut dyn Write) -> Result<(), ClientFailure> {
655 let mut line: String = encode_client(&ClientMessage::Work { git_dir: git_dir.to_path_buf(), review_key: review_key.cloned() });
656 line.push('\n');
657 let mut announced: bool = false;
658 let mut reader: BufReader<UnixStream> = reach_current(config, notices, &mut announced, &mut ReachTrace::default(), false)?;
659 if reader.get_mut().write_all(line.as_bytes()).is_err() {
660 return Err(ClientFailure::Unavailable);
661 }
662 // The service closes the connection after it has read the line.
663 while read_message(&mut reader).is_some() {}
664 return Ok(());
665}
666
667/// The client's bounds and how it reads the service's lines.
668#[cfg(test)]
669#[path = "service_client_tests.rs"]
670mod tests;