Skip to main content

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