git_policy_cli/transaction/transaction_phase.rs
1//! What:
2//! Test-only phase markers of the isolated merge and of a commit headed for the service, for crash and interleaving suites.
3//! An explicit environment variable arms one marker:
4//! `CLI_GIT_TEST_ONLY_PHASE_SIGNAL=<phase>:kill` ends the process with `SIGKILL` at the phase,
5//! `<phase>:kill:<directory>` writes `<directory>/<phase>.reached` first,
6//! and `<phase>:pause:<directory>` writes that marker and waits until `<directory>/<phase>.release` exists.
7//! A repeatable phase numbers its files by occurrence: `<phase>-<n>.reached`.
8//! Why:
9//! Crash suites need a deterministic point to kill the wrapper.
10//! The marker is compiled into the release executable, but only the explicit variable arms it,
11//! and a malformed value fails the invocation at every phase it reaches instead of being ignored
12//! (`src/policy-engine/commit-transaction-test-phase.ts`).
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! await reachTransactionPhase({ phase: 'merge-applied' });
17//! ```
18
19/// Diagnostic output to standard error when a marker fires.
20use crate::diagnostic_log::warn;
21/// Strict JSON string quoting for the refusal message, as `JSON.stringify` writes it.
22use serde_json::to_string as json_string;
23/// `OsStr` is borrowed operating-system text, and `Path`/`PathBuf` are filesystem paths.
24use std::path::{Path, PathBuf};
25use std::time::Duration;
26
27/// Environment variable arming one phase marker.
28pub const PHASE_SIGNAL_VARIABLE: &str = "CLI_GIT_TEST_ONLY_PHASE_SIGNAL";
29
30/// Poll interval while paused.
31use crate::wait_ceilings::PHASE_PATIENCE;
32
33const RELEASE_POLL: Duration = Duration::from_millis(20);
34
35/// What:
36/// One phase a marker can name.
37/// Why:
38/// The isolated merge reaches two of them and a commit headed for the service the other two; the phases of the landing
39/// that the commit service replaced (`capture-locked` to `index-installed`, `race-lost`) are gone, and a value naming one
40/// is refused like any unknown phase.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// type TestPhase = 'merge-intent' | 'merge-applied' | 'service-staged' | 'service-prepared';
45/// ```
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum Phase {
48 /// An isolated merge wrote its intent record and has not yet asked the checkout to be updated.
49 MergeIntent,
50 /// The checkout was updated and the branch moved for an isolated merge, whose intent record is still on disk.
51 MergeApplied,
52 /// A commit headed for the service has staged its content in a private index and has not yet read it back for the
53 /// policies; repeatable, numbered by occurrence (a commit prepared again reaches it again).
54 ServiceStaged,
55 /// A commit headed for the service is fully prepared and has not yet been sent; repeatable, numbered by occurrence.
56 ServicePrepared,
57}
58
59/// Every phase in the order a command reaches them, which the refusal message lists.
60pub const TRANSACTION_TEST_PHASES: [Phase; 4] = [
61 Phase::MergeIntent,
62 Phase::MergeApplied,
63 Phase::ServiceStaged,
64 Phase::ServicePrepared,
65];
66
67/// Phase names in the order a command reaches them.
68impl Phase {
69 /// What:
70 /// The phase's name as the marker variable spells it.
71 /// Why:
72 /// Marker files and the refusal message use these names.
73 ///
74 /// In TS you'd write (pseudocode):
75 /// ```ts
76 /// phase // 'merge-intent'
77 /// ```
78 pub fn name(self) -> &'static str {
79 match self {
80 Phase::MergeIntent => return "merge-intent",
81 Phase::MergeApplied => return "merge-applied",
82 Phase::ServiceStaged => return "service-staged",
83 Phase::ServicePrepared => return "service-prepared",
84 }
85 }
86}
87
88/// What:
89/// What a marker does at its phase.
90/// Why:
91/// `kill` ends the process; `pause` hands control to the test until the release file exists.
92///
93/// In TS you'd write (pseudocode):
94/// ```ts
95/// action: 'kill' | 'pause'
96/// ```
97#[derive(Clone, Copy, Debug, Eq, PartialEq)]
98pub enum PhaseAction {
99 /// Ends the process with `SIGKILL`.
100 Kill,
101 /// Waits for the release file.
102 Pause,
103}
104
105/// What:
106/// One parsed marker.
107/// Why:
108/// Parsing happens at every phase reached, so a malformed value fails wherever it is read.
109///
110/// In TS you'd write (pseudocode):
111/// ```ts
112/// type PhaseSignal = { phase: TransactionTestPhase; action: 'kill' | 'pause'; directory?: string };
113/// ```
114#[derive(Clone, Debug, Eq, PartialEq)]
115pub struct PhaseSignal {
116 /// Armed phase.
117 pub phase: Phase,
118 /// What happens at the phase.
119 pub action: PhaseAction,
120 /// Marker directory; required for `pause`, absent or empty otherwise.
121 pub directory: Option<PathBuf>,
122}
123
124/// What:
125/// The marker variable is malformed.
126/// Why:
127/// The invocation fails with this text instead of ignoring the value.
128///
129/// In TS you'd write (pseudocode):
130/// ```ts
131/// new TestPhaseSignalError(value)
132/// ```
133#[derive(Clone, Debug, Eq, PartialEq)]
134pub struct PhaseSignalError {
135 /// The rejected value, as read.
136 pub value: String,
137}
138
139/// Message text naming the variable, the quoted value and the accepted forms.
140impl std::fmt::Display for PhaseSignalError {
141 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 let quoted: String = json_string(self.value.as_str()).unwrap_or_else(|_| format!("{:?}", self.value));
143 let phases: Vec<&str> = TRANSACTION_TEST_PHASES.iter().map(|phase| { return phase.name(); }).collect();
144 return write!(
145 formatter,
146 "{PHASE_SIGNAL_VARIABLE}={quoted} is not <phase>:kill[:<absolute directory>] or <phase>:pause:<absolute directory> with a phase among {}.",
147 phases.join(", ")
148 )
149 }
150}
151
152/// Error type of the marker reach.
153impl std::error::Error for PhaseSignalError {}
154
155/// What:
156/// Why a reached phase could not be completed.
157/// Why:
158/// A malformed value and a failed marker write are both fatal to the invocation.
159///
160/// In TS you'd write (pseudocode):
161/// ```ts
162/// TestPhaseSignalError | NodeJS.ErrnoException
163/// ```
164#[derive(Debug)]
165pub enum PhaseMarkerError {
166 /// The marker variable is malformed.
167 Malformed(PhaseSignalError),
168 /// Writing the reached marker or polling the release file failed.
169 Io(std::io::Error),
170}
171
172/// Message text of either failure.
173impl std::fmt::Display for PhaseMarkerError {
174 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 match self {
176 PhaseMarkerError::Malformed(error) => return write!(formatter, "{error}"),
177 PhaseMarkerError::Io(error) => return write!(formatter, "transaction phase marker failed: {error}"),
178 }
179 }
180}
181
182/// Error type of the marker reach.
183impl std::error::Error for PhaseMarkerError {}
184
185/// What:
186/// Parses the marker variable.
187/// Why:
188/// The phase must match exactly, the action must be `kill` or `pause`,
189/// a pause needs a directory, and a directory must be absolute.
190/// An empty directory after `kill` counts as no directory, as the incumbent's parser reads it.
191///
192/// In TS you'd write (pseudocode):
193/// ```ts
194/// parsePhaseSignal(value);
195/// ```
196pub fn parse_phase_signal(value: &str) -> Result<PhaseSignal, PhaseSignalError> {
197 let refusal = || {
198 return PhaseSignalError {
199 value: value.to_string(),
200 };
201 };
202 let Some(phase_end) = value.find(':') else {
203 return Err(refusal());
204 };
205 let rest: &str = &value[phase_end + 1..];
206 let (action_text, directory_text) = match rest.find(':') {
207 Some(action_end) => (&rest[..action_end], &rest[action_end + 1..]),
208 None => (rest, ""),
209 };
210 let Some(phase) = TRANSACTION_TEST_PHASES
211 .iter()
212 .copied()
213 .find(|candidate| { return candidate.name() == &value[..phase_end]; })
214 else {
215 return Err(refusal());
216 };
217 let action: PhaseAction = match action_text {
218 "kill" => PhaseAction::Kill,
219 "pause" => PhaseAction::Pause,
220 _ => return Err(refusal()),
221 };
222 if (action == PhaseAction::Pause && directory_text.is_empty())
223 || (!directory_text.is_empty() && !Path::new(directory_text).is_absolute())
224 {
225 return Err(refusal());
226 }
227 return Ok(PhaseSignal {
228 phase,
229 action,
230 directory: if directory_text.is_empty() {
231 None
232 } else {
233 Some(PathBuf::from(directory_text))
234 },
235 });
236}
237
238/// What:
239/// Ends the process with `SIGKILL`, the signal no handler can catch.
240/// Why:
241/// A crash suite needs the process to die with no cleanup, as a power loss would leave it.
242///
243/// In TS you'd write (pseudocode):
244/// ```ts
245/// process.kill(process.pid, 'SIGKILL');
246/// ```
247#[cfg(unix)]
248fn kill_self() -> ! {
249 // SAFETY: `getpid` and `kill` take no pointers, and signalling this process with `SIGKILL` cannot return.
250 unsafe {
251 libc::kill(libc::getpid(), libc::SIGKILL);
252 }
253 std::process::abort()
254}
255
256/// What:
257/// Ends the process at once on platforms without `SIGKILL`, running no cleanup.
258/// Why:
259/// The marker must not run destructors, as an uncatchable kill would not.
260///
261/// In TS you'd write (pseudocode):
262/// ```ts
263/// process.kill(process.pid, 'SIGKILL');
264/// ```
265// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
266#[cfg(not(unix))]
267fn kill_self() -> ! {
268 std::process::exit(137)
269}
270
271/// What:
272/// Waits until the release file exists, polling every 20 milliseconds, and gives up with an error when the directory the
273/// test named for its markers is gone.
274/// Why:
275/// A paused phase lets a test inspect the repository before the transaction continues. The directory is the test's fixture,
276/// which a test removes as it ends, however it ended; once it is gone nobody can write the release file, and a process that
277/// went on polling would run for ever in a directory that no longer exists. The wait watches the fixture first, and a clock
278/// second: the patience is the longest a test may run, so a slow host never cuts a pause short and a pause never outlives its test.
279///
280/// In TS you'd write (pseudocode):
281/// ```ts
282/// for (;;) {
283/// if (await exists(release)) return;
284/// if (!(await exists(directory))) throw new Error('the directory of the pause is gone');
285/// await wait(20);
286/// }
287/// ```
288///
289/// # Errors
290/// An I/O error of kind `NotFound` when the directory of the pause no longer exists.
291fn await_release(directory: &Path, release: &Path) -> Result<(), PhaseMarkerError> {
292 return await_release_within(directory, release, PHASE_PATIENCE);
293}
294
295/// `await_release` that gives up with an error of kind `TimedOut` after `patience`: the owner's rule is that nothing waits for ever
296/// (2026-11-03), and a control that armed a pause and then died leaves a directory that still exists.
297///
298/// # Errors
299/// As `await_release`, and `TimedOut` when the release file did not come in time.
300fn await_release_within(directory: &Path, release: &Path, patience: Duration) -> Result<(), PhaseMarkerError> {
301 let began: std::time::Instant = std::time::Instant::now();
302 while !release.exists() {
303 if began.elapsed() >= patience {
304 return Err(PhaseMarkerError::Io(std::io::Error::new(
305 std::io::ErrorKind::TimedOut,
306 format!("nothing released the pause in {} within {} seconds", directory.display(), patience.as_secs()),
307 )));
308 }
309 if !directory.exists() {
310 return Err(PhaseMarkerError::Io(std::io::Error::new(
311 std::io::ErrorKind::NotFound,
312 format!("the directory {} of the pause is gone, so nothing can release it", directory.display()),
313 )));
314 }
315 std::thread::sleep(RELEASE_POLL);
316 }
317 return Ok(());
318}
319
320/// What:
321/// Reaches one phase with the marker variable's value, killing or pausing when it is armed for this phase.
322/// Why:
323/// Tests pass the value directly; the production entry reads the process environment.
324///
325/// In TS you'd write (pseudocode):
326/// ```ts
327/// await reachTransactionPhase({ phase, occurrence, environment });
328/// ```
329pub(crate) fn reach_with_value(
330 value: Option<&str>,
331 phase: Phase,
332 occurrence: Option<u64>,
333) -> Result<(), PhaseMarkerError> {
334 let Some(armed) = value.filter(|text| { return !text.is_empty(); }) else {
335 return Ok(());
336 };
337 let signal: PhaseSignal = parse_phase_signal(armed).map_err(PhaseMarkerError::Malformed)?;
338 if signal.phase != phase {
339 return Ok(());
340 }
341 let stem: String = match occurrence {
342 Some(number) => format!("{}-{number}", phase.name()),
343 None => phase.name().to_string(),
344 };
345 warn(
346 "transactionPhase",
347 &format!("{PHASE_SIGNAL_VARIABLE} reached {stem}: {:?}", signal.action),
348 );
349 if let Some(directory) = signal.directory.as_deref() {
350 std::fs::write(directory.join(format!("{stem}.reached")), std::process::id().to_string())
351 .map_err(PhaseMarkerError::Io)?;
352 }
353 match signal.action {
354 PhaseAction::Kill => kill_self(),
355 PhaseAction::Pause => {
356 let directory: &Path = signal.directory.as_deref().unwrap_or(Path::new(""));
357 return await_release(directory, &directory.join(format!("{stem}.release")));
358 }
359 }
360}
361
362/// What:
363/// Reaches one phase, reading the marker variable from the process environment.
364/// Why:
365/// A command calls this at each marked phase; an unset variable costs one environment lookup.
366///
367/// In TS you'd write (pseudocode):
368/// ```ts
369/// await reachTransactionPhase({ phase: 'merge-intent' });
370/// ```
371pub fn reach_transaction_phase(phase: Phase, occurrence: Option<u64>) -> Result<(), PhaseMarkerError> {
372 let value: Option<String> = std::env::var_os(PHASE_SIGNAL_VARIABLE).map(|text: std::ffi::OsString| { return text.to_string_lossy().into_owned(); });
373 return reach_with_value(value.as_deref(), phase, occurrence);
374}
375
376/// Phase marker controls stay out of the production code path.
377#[cfg(test)]
378#[path = "transaction_phase_tests.rs"]
379mod tests;