Skip to main content

nextest_runner/record/
session.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Recording session management.
5//!
6//! This module provides [`RecordSession`], which encapsulates the full lifecycle of
7//! a recording session: setup, integration with the reporter, and finalization.
8//! This allows both `run` and `bench` commands to share recording logic.
9
10use super::{
11    CompletedRunStats, RecordedRunStatus, RunRecorder, RunStore, ShortestRunIdPrefix, StoreSizes,
12    StressCompletedRunStats, records_state_dir,
13    retention::{PruneResult, RecordRetentionPolicy},
14};
15use crate::{
16    errors::{RecordPruneError, RecordSetupError, RunStoreError},
17    record::{Styles, format::RerunInfo},
18    reporter::{
19        RunFinishedInfo,
20        events::{FinalRunStats, RunFinishedStats, StressFinalRunStats},
21    },
22};
23use bytesize::ByteSize;
24use camino::{Utf8Path, Utf8PathBuf};
25use chrono::{DateTime, FixedOffset};
26use owo_colors::OwoColorize;
27use quick_junit::ReportUuid;
28use semver::Version;
29use std::{collections::BTreeMap, fmt};
30
31/// Configuration for creating a recording session.
32#[derive(Clone, Debug)]
33pub struct RecordSessionConfig<'a> {
34    /// The workspace root path, used to determine the state directory.
35    pub workspace_root: &'a Utf8Path,
36    /// The unique identifier for this run.
37    pub run_id: ReportUuid,
38    /// The version of nextest creating this recording.
39    pub nextest_version: Version,
40    /// When the run started.
41    pub started_at: DateTime<FixedOffset>,
42    /// The command-line arguments used to invoke nextest.
43    pub cli_args: Vec<String>,
44    /// Build scope arguments (package and target selection).
45    ///
46    /// These determine which packages and targets are built. In a rerun chain,
47    /// these are inherited from the original run unless explicitly overridden.
48    pub build_scope_args: Vec<String>,
49    /// Environment variables that affect nextest behavior (NEXTEST_* and CARGO_*).
50    pub env_vars: BTreeMap<String, String>,
51    /// Maximum size per output file before truncation.
52    pub max_output_size: ByteSize,
53    /// Rerun-specific metadata, if this is a rerun.
54    ///
55    /// If present, this will be written to `meta/rerun-info.json` in the archive.
56    pub rerun_info: Option<RerunInfo>,
57}
58
59/// Result of setting up a recording session.
60#[derive(Debug)]
61pub struct RecordSessionSetup {
62    /// The session handle for later finalization.
63    pub session: RecordSession,
64    /// The recorder to pass to the structured reporter.
65    pub recorder: RunRecorder,
66}
67
68/// Manages the full lifecycle of a recording session.
69///
70/// This type encapsulates setup, execution integration, and finalization.
71#[derive(Debug)]
72pub struct RecordSession {
73    state_dir: Utf8PathBuf,
74    run_id: ReportUuid,
75    run_id_unique_prefix: ShortestRunIdPrefix,
76}
77
78impl RecordSession {
79    /// Sets up a new recording session.
80    ///
81    /// Creates the run store, acquires an exclusive lock, and creates the
82    /// recorder. The lock is released after setup completes (the recorder
83    /// writes independently).
84    ///
85    /// Returns a setup result containing the session handle and recorder, or an
86    /// error if setup fails.
87    pub fn setup(config: RecordSessionConfig<'_>) -> Result<RecordSessionSetup, RecordSetupError> {
88        let state_dir =
89            records_state_dir(config.workspace_root).map_err(RecordSetupError::StateDirNotFound)?;
90
91        let store = RunStore::new(&state_dir).map_err(RecordSetupError::StoreCreate)?;
92
93        let locked_store = store
94            .lock_exclusive()
95            .map_err(RecordSetupError::StoreLock)?;
96
97        let (mut recorder, run_id_unique_prefix) = locked_store
98            .create_run_recorder(
99                config.run_id,
100                config.nextest_version,
101                config.started_at,
102                config.cli_args,
103                config.build_scope_args,
104                config.env_vars,
105                config.max_output_size,
106                config.rerun_info.as_ref().map(|info| info.parent_run_id),
107            )
108            .map_err(RecordSetupError::RecorderCreate)?;
109
110        // If this is a rerun, write the rerun info to the archive.
111        if let Some(rerun_info) = config.rerun_info {
112            recorder
113                .write_rerun_info(&rerun_info)
114                .map_err(RecordSetupError::RecorderCreate)?;
115        }
116
117        let session = RecordSession {
118            state_dir,
119            run_id: config.run_id,
120            run_id_unique_prefix,
121        };
122
123        Ok(RecordSessionSetup { session, recorder })
124    }
125
126    /// Returns the run ID for this session.
127    pub fn run_id(&self) -> ReportUuid {
128        self.run_id
129    }
130
131    /// Returns the shortest unique prefix for this session's run ID.
132    pub fn run_id_unique_prefix(&self) -> &ShortestRunIdPrefix {
133        &self.run_id_unique_prefix
134    }
135
136    /// Returns the state directory for this session.
137    pub fn state_dir(&self) -> &Utf8Path {
138        &self.state_dir
139    }
140
141    /// Finalizes the recording session after the run completes.
142    ///
143    /// This method marks the run as completed with its final sizes and stats.
144    ///
145    /// All errors during finalization are non-fatal and returned as warnings,
146    /// since the recording itself has already completed successfully.
147    ///
148    /// This should be called after `reporter.finish()` returns the recording sizes.
149    ///
150    /// The `exit_code` parameter should be the exit code that the process will
151    /// return. This is stored in the run metadata for later inspection.
152    pub fn finalize(
153        self,
154        recording_sizes: Option<StoreSizes>,
155        run_finished: Option<RunFinishedInfo>,
156        exit_code: i32,
157        policy: &RecordRetentionPolicy,
158    ) -> RecordFinalizeResult {
159        let mut result = RecordFinalizeResult::default();
160
161        // If recording didn't produce sizes, there's nothing to finalize.
162        let Some(sizes) = recording_sizes else {
163            return result;
164        };
165
166        // Convert run finished info to status and duration.
167        let (status, duration_secs) = match run_finished {
168            Some(info) => (
169                convert_run_stats_to_status(info.stats, exit_code),
170                Some(info.elapsed.as_secs_f64()),
171            ),
172            // This shouldn't happen when recording_sizes is Some, but handle gracefully.
173            None => (RecordedRunStatus::Incomplete, None),
174        };
175
176        // Re-open the store and acquire the lock.
177        let store = match RunStore::new(&self.state_dir) {
178            Ok(store) => store,
179            Err(err) => {
180                result
181                    .warnings
182                    .push(RecordFinalizeWarning::StoreOpenFailed(err));
183                return result;
184            }
185        };
186
187        let mut locked_store = match store.lock_exclusive() {
188            Ok(locked) => locked,
189            Err(err) => {
190                result
191                    .warnings
192                    .push(RecordFinalizeWarning::StoreLockFailed(err));
193                return result;
194            }
195        };
196
197        // Mark the run as completed and persist.
198        match locked_store.complete_run(self.run_id, sizes, status, duration_secs) {
199            Ok(true) => {}
200            Ok(false) => {
201                // Run was not found in the store, likely pruned during execution.
202                result
203                    .warnings
204                    .push(RecordFinalizeWarning::RunNotFoundDuringComplete(
205                        self.run_id,
206                    ));
207            }
208            Err(err) => {
209                result
210                    .warnings
211                    .push(RecordFinalizeWarning::MetadataPersistFailed(err));
212            }
213        }
214        // Continue with pruning even if metadata persistence failed.
215
216        // Prune old runs if needed (once daily or if limits exceeded by 1.5x).
217        match locked_store.prune_if_needed(policy) {
218            Ok(Some(mut prune_result)) => {
219                // Move any errors that occurred during pruning into warnings.
220                for error in prune_result.errors.drain(..) {
221                    result
222                        .warnings
223                        .push(RecordFinalizeWarning::PruneError(error));
224                }
225                result.prune_result = Some(prune_result);
226            }
227            Ok(None) => {
228                // Pruning was skipped; nothing to do.
229            }
230            Err(err) => {
231                result
232                    .warnings
233                    .push(RecordFinalizeWarning::PruneFailed(err));
234            }
235        }
236
237        result
238    }
239}
240
241/// Converts `RunFinishedStats` to `RecordedRunStatus`.
242fn convert_run_stats_to_status(stats: RunFinishedStats, exit_code: i32) -> RecordedRunStatus {
243    match stats {
244        RunFinishedStats::Single(run_stats) => {
245            let completed_stats = CompletedRunStats {
246                initial_run_count: run_stats.initial_run_count,
247                passed: run_stats.passed,
248                failed: run_stats.failed_count(),
249                exit_code,
250            };
251
252            // Check if the run was cancelled based on final stats.
253            match run_stats.summarize_final() {
254                FinalRunStats::Success
255                | FinalRunStats::NoTestsRun
256                | FinalRunStats::Failed { .. } => RecordedRunStatus::Completed(completed_stats),
257                FinalRunStats::Cancelled { .. } => RecordedRunStatus::Cancelled(completed_stats),
258            }
259        }
260        RunFinishedStats::Stress(stress_stats) => {
261            let stress_completed_stats = StressCompletedRunStats {
262                initial_iteration_count: stress_stats.completed.total,
263                success_count: stress_stats.success_count,
264                failed_count: stress_stats.failed_count,
265                exit_code,
266            };
267
268            // Check if the stress run was cancelled.
269            match stress_stats.summarize_final() {
270                StressFinalRunStats::Success
271                | StressFinalRunStats::NoTestsRun
272                | StressFinalRunStats::Failed => {
273                    RecordedRunStatus::StressCompleted(stress_completed_stats)
274                }
275                StressFinalRunStats::Cancelled => {
276                    RecordedRunStatus::StressCancelled(stress_completed_stats)
277                }
278            }
279        }
280    }
281}
282
283/// Result of finalizing a recording session.
284#[derive(Debug, Default)]
285pub struct RecordFinalizeResult {
286    /// Warnings encountered during finalization.
287    pub warnings: Vec<RecordFinalizeWarning>,
288    /// The prune result, if pruning was performed.
289    pub prune_result: Option<PruneResult>,
290}
291
292impl RecordFinalizeResult {
293    /// Logs warnings and pruning statistics from the finalization result.
294    pub fn log(&self, styles: &Styles) {
295        for warning in &self.warnings {
296            tracing::warn!("{warning}");
297        }
298
299        if let Some(prune_result) = &self.prune_result
300            && (prune_result.deleted_count > 0 || prune_result.orphans_deleted > 0)
301        {
302            tracing::info!(
303                "{}(hint: {} to replay runs)",
304                prune_result.display(styles),
305                "cargo nextest replay".style(styles.count),
306            );
307        }
308    }
309}
310
311/// Non-fatal warning during recording finalization.
312#[derive(Debug)]
313pub enum RecordFinalizeWarning {
314    /// Recording completed but the run store couldn't be opened.
315    StoreOpenFailed(RunStoreError),
316    /// Recording completed but the run store couldn't be locked.
317    StoreLockFailed(RunStoreError),
318    /// Recording completed but run metadata couldn't be persisted.
319    MetadataPersistFailed(RunStoreError),
320    /// Recording completed but the run was not found in the store.
321    ///
322    /// This can happen if an aggressive prune deleted the run while the test
323    /// was still executing.
324    RunNotFoundDuringComplete(ReportUuid),
325    /// Error during pruning (overall prune operation failed).
326    PruneFailed(RunStoreError),
327    /// Error during pruning (individual run or orphan deletion failed).
328    PruneError(RecordPruneError),
329}
330
331impl fmt::Display for RecordFinalizeWarning {
332    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
333        match self {
334            Self::StoreOpenFailed(err) => {
335                write!(f, "recording completed but failed to open run store: {err}")
336            }
337            Self::StoreLockFailed(err) => {
338                write!(f, "recording completed but failed to lock run store: {err}")
339            }
340            Self::MetadataPersistFailed(err) => {
341                write!(
342                    f,
343                    "recording completed but failed to persist run metadata: {err}"
344                )
345            }
346            Self::RunNotFoundDuringComplete(run_id) => {
347                write!(
348                    f,
349                    "recording completed but run {run_id} was not found in store \
350                     (may have been pruned during execution)"
351                )
352            }
353            Self::PruneFailed(err) => write!(f, "error during prune: {err}"),
354            Self::PruneError(msg) => write!(f, "error during prune: {msg}"),
355        }
356    }
357}