Skip to main content

git_policy_cli/
transaction_phase.rs

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