Skip to main content

ic_build/artifacts/staging/
mod.rs

1//! Invocation-owned artifact staging without cache publication or reuse.
2//!
3//! Callers own the parent directory, recipes and publication boundary. This
4//! module allocates only its own child and removes it after the last owner is
5//! dropped. Explicit retention transfers cleanup to the caller.
6//! It never prunes existing artifacts or publishes a product release.
7
8use std::{
9    fs, io,
10    path::{Path, PathBuf},
11    sync::atomic::{AtomicU64, Ordering},
12};
13
14use ic_host_fs::{durable::write_bytes, read::read_file};
15
16use super::cache_fs::remove_path_if_present;
17
18const STAGE_ATTEMPTS: usize = 64;
19static STAGE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
20
21/// Temporary artifact directory retained until its owner is closed or dropped.
22///
23/// The caller keeps custody of the parent and allocated child against unrelated
24/// writers. Drop performs best-effort cleanup of this invocation-owned child;
25/// use [`Self::close`] when cleanup failures must reach the caller. No shared
26/// cache entry is created, reused or removed.
27#[derive(Debug)]
28pub struct ArtifactStagingDirectory {
29    root: PathBuf,
30    armed: bool,
31}
32
33impl ArtifactStagingDirectory {
34    /// Allocate a distinct staging child beneath an existing caller-owned directory.
35    ///
36    /// The parent is not created. Existing children are never reused or removed.
37    ///
38    /// # Errors
39    /// Returns the filesystem error for a missing or unwritable parent, or
40    /// `AlreadyExists` when every allocation attempt collides.
41    pub fn new(parent: &Path) -> io::Result<Self> {
42        for _ in 0..STAGE_ATTEMPTS {
43            let sequence = STAGE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
44            let root = parent.join(format!(
45                ".ic-build-artifact-stage-{}-{sequence}",
46                std::process::id()
47            ));
48            match fs::create_dir(&root) {
49                Ok(()) => return Ok(Self { root, armed: true }),
50                Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
51                Err(source) => return Err(source),
52            }
53        }
54        Err(io::Error::new(
55            io::ErrorKind::AlreadyExists,
56            format!(
57                "failed to allocate artifact staging beneath {}",
58                parent.display()
59            ),
60        ))
61    }
62
63    /// Borrow the owned staging directory through artifact production and consumption.
64    #[must_use]
65    pub fn path(&self) -> &Path {
66        &self.root
67    }
68
69    /// Remove this owned staging child, reporting cleanup failure without a hidden retry.
70    ///
71    /// On failure, the caller owns recovery of the remaining staging child.
72    /// It does not remove the parent or any other artifact directory.
73    ///
74    /// # Errors
75    /// Returns the filesystem error if removing the owned child fails.
76    pub fn close(mut self) -> io::Result<()> {
77        self.armed = false;
78        remove_path_if_present(&self.root)
79    }
80}
81
82impl Drop for ArtifactStagingDirectory {
83    fn drop(&mut self) {
84        if self.armed {
85            let _ = remove_path_if_present(&self.root);
86        }
87    }
88}
89
90/// Owned copy of one completed producer output, independent of later source replacement.
91///
92/// Keep this record alive through every use of [`Self::path`]. The copy is
93/// temporary rather than a reusable cache entry; ordinary Drop removes it.
94/// [`Self::retain`] explicitly transfers cleanup to the caller.
95#[derive(Debug)]
96pub struct CapturedArtifact {
97    staging: ArtifactStagingDirectory,
98    path: PathBuf,
99}
100
101impl CapturedArtifact {
102    /// Capture an output synchronously before another producer can overwrite it.
103    ///
104    /// The caller keeps the source stable during capture and selects the maximum
105    /// readable byte count. Read/publication failures retain their typed causes
106    /// inside the returned I/O error. Existing public destinations are untouched.
107    ///
108    /// # Errors
109    /// Returns the source read/byte-limit, staging allocation or durable-write
110    /// error. An unsuccessful capture performs best-effort staging cleanup.
111    pub fn capture(source: &Path, parent: &Path, maximum_bytes: usize) -> io::Result<Self> {
112        let bytes = read_file(source, maximum_bytes).map_err(io::Error::from)?;
113        let staging = ArtifactStagingDirectory::new(parent)?;
114        let path = staging.path().join("input.artifact");
115        write_bytes(&path, &bytes).map_err(io::Error::other)?;
116        Ok(Self { staging, path })
117    }
118
119    /// Borrow the exact captured bytes while this record owns their lifetime.
120    #[must_use]
121    pub fn path(&self) -> &Path {
122        &self.path
123    }
124
125    /// Transfer the captured file and its staging child to caller-owned retention.
126    ///
127    /// Unlike close or Drop, this keeps the exact captured bytes after this owner
128    /// is consumed. The caller owns later cleanup of the returned path's parent.
129    /// Keep custody of those paths; this grants no cache or release admission.
130    #[must_use]
131    pub fn retain(mut self) -> PathBuf {
132        self.staging.armed = false;
133        self.path
134    }
135
136    /// Remove the captured output and its owned staging child, reporting cleanup failure.
137    ///
138    /// # Errors
139    /// Returns the filesystem error if removing the owned child fails.
140    pub fn close(self) -> io::Result<()> {
141        self.staging.close()
142    }
143}
144
145#[cfg(test)]
146mod tests;