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