#![deny(clippy::arithmetic_side_effects)]
use super::build_id::{self, BlockHasher, build_id_size};
use super::chunks::{self, ChunkRange, LayoutError};
use super::mmap::map_output;
use super::positional;
use super::stats::{Backing, WritePhase, WriteStats};
use super::written::{self, HashPlan, Precomputed};
use crate::args::{BuildId, CancelToken, LinkOptions, OutputBuffer};
use crate::error::{Error, Result};
use memmap2::MmapMut;
use std::ffi::OsString;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::thread::JoinHandle;
use std::time::Instant;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FileMode {
#[default]
Executable,
Regular,
Exact(u32),
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ReplaceStrategy {
#[default]
Rename,
Atomic,
Unlink,
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BackingPolicy {
#[default]
Auto,
Mapped,
Written,
Buffered,
}
impl BackingPolicy {
pub const ENV: &'static str = "QLD_OUTPUT_BACKING";
pub const DEFAULT: Self = Self::Written;
#[must_use]
pub fn from_option(backing: crate::args::OutputBacking) -> Self {
match backing {
crate::args::OutputBacking::Mapped => Self::Mapped,
crate::args::OutputBacking::Written => Self::Written,
crate::args::OutputBacking::Buffered => Self::Buffered,
}
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
match name {
"auto" => Some(Self::Auto),
"mmap" | "mapped" => Some(Self::Mapped),
"write" | "written" | "pwrite" => Some(Self::Written),
"memory" | "buffer" | "buffered" => Some(Self::Buffered),
_ => None,
}
}
#[must_use]
pub fn resolve(self) -> Self {
match self {
Self::Auto => Self::DEFAULT,
other => other,
}
}
}
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OutputOptions {
pub mode: FileMode,
pub replace: ReplaceStrategy,
pub sync: bool,
pub background_release_threshold: Option<u64>,
pub backing: BackingPolicy,
pub capture: Option<OutputBuffer>,
pub cancel: Option<CancelToken>,
}
impl OutputOptions {
pub const DEFAULT_RELEASE_THRESHOLD: u64 = 32 << 20;
#[must_use]
pub fn for_link(options: &LinkOptions) -> Self {
Self {
capture: options.output_buffer.clone(),
cancel: options.cancel.clone(),
backing: options
.output_backing
.map_or(BackingPolicy::Auto, BackingPolicy::from_option),
..Self::default()
}
}
fn check_cancelled(&self) -> Result<()> {
self.cancel.as_ref().map_or(Ok(()), CancelToken::check)
}
}
impl Default for OutputOptions {
fn default() -> Self {
Self {
mode: FileMode::Executable,
replace: ReplaceStrategy::Rename,
sync: false,
background_release_threshold: Some(Self::DEFAULT_RELEASE_THRESHOLD),
backing: BackingPolicy::Auto,
capture: None,
cancel: None,
}
}
}
enum Storage {
Mapped(MmapMut),
Buffer(Vec<u8>),
Positional(Positional),
}
struct Positional {
len: usize,
written: bool,
plan: Option<HashPlan>,
precomputed: Option<Precomputed>,
}
impl Positional {
fn touch(&mut self) {
self.written = true;
self.precomputed = None;
}
}
impl Storage {
fn len(&self) -> usize {
match self {
Self::Mapped(map) => map.len(),
Self::Buffer(buf) => buf.len(),
Self::Positional(state) => state.len,
}
}
}
fn destination_file(destination: &Destination) -> Option<&File> {
match destination {
Destination::Temp { file, .. } | Destination::Direct { file } => Some(file),
Destination::Stream { .. } | Destination::Memory | Destination::Done => None,
}
}
fn io_error(path: Option<&Path>, error: io::Error) -> Error {
match path {
Some(path) => Error::io(path, error),
None => Error::from(error),
}
}
fn no_file() -> Error {
Error::Internal("positional output without a file".into())
}
enum Destination {
Memory,
Temp { file: File, temp: PathBuf },
Direct { file: File },
Stream { file: File },
Done,
}
pub struct OutputFile {
path: Option<PathBuf>,
storage: Storage,
destination: Destination,
options: OutputOptions,
release: Option<JoinHandle<()>>,
stats: WriteStats,
}
impl std::fmt::Debug for OutputFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OutputFile")
.field("path", &self.path)
.field("len", &self.len())
.field("backing", &self.stats.backing)
.finish_non_exhaustive()
}
}
impl OutputFile {
pub fn create(path: &Path, size: u64, options: &OutputOptions) -> Result<Self> {
options.check_cancelled()?;
if options.capture.is_some() {
let mut out = Self::in_memory(size)?;
out.options = options.clone();
return Ok(out);
}
let start = Instant::now();
let len = usize::try_from(size).map_err(|_| Error::io(path, too_large()))?;
let target = fs::metadata(path).ok();
let is_special = target.as_ref().is_some_and(|meta| !meta.is_file());
let mut release = None;
let destination = if is_special || (target.is_some() && is_system_path(path)) {
let file = OpenOptions::new()
.write(true)
.truncate(!is_special)
.open(path)
.map_err(|e| Error::io(path, e))?;
Destination::Stream { file }
} else {
let temp = match options.replace {
ReplaceStrategy::Rename | ReplaceStrategy::Atomic => {
create_temp(path, options.mode).ok()
}
ReplaceStrategy::Unlink => None,
};
match temp {
Some((file, temp)) => Destination::Temp { file, temp },
None => {
if let Some(old) = hold_old_output(path, options.background_release_threshold) {
release = release_in_background(old);
}
let _ = fs::remove_file(path);
let file = open_options(options.mode)
.truncate(true)
.open(path)
.map_err(|e| Error::io(path, e))?;
Destination::Direct { file }
}
}
};
let mut out = Self {
path: Some(path.to_path_buf()),
storage: Storage::Buffer(Vec::new()),
destination,
options: options.clone(),
release,
stats: WriteStats::new(size, Backing::Buffered),
};
out.stats.background_release = out.release.is_some();
let mut storage = None;
if let Some(file) = destination_file(&out.destination) {
file.set_len(size).map_err(|e| Error::io(path, e))?;
let policy = match options.backing.resolve() {
BackingPolicy::Written if !positional::SUPPORTED => BackingPolicy::Mapped,
policy => policy,
};
if len != 0 {
storage = match policy {
BackingPolicy::Written => Some((
Backing::Written,
Storage::Positional(Positional {
len,
written: false,
plan: None,
precomputed: None,
}),
)),
BackingPolicy::Mapped | BackingPolicy::Auto => map_output(file, len)
.ok()
.map(|map| (Backing::Mapped, Storage::Mapped(map))),
BackingPolicy::Buffered => None,
};
}
}
out.storage = match storage {
Some((backing, storage)) => {
out.stats.backing = backing;
storage
}
None => Storage::Buffer(zeroed(len).map_err(|e| Error::io(path, e))?),
};
out.stats.record(WritePhase::Open, start.elapsed());
Ok(out)
}
pub fn in_memory(size: u64) -> Result<Self> {
let start = Instant::now();
let len = usize::try_from(size).map_err(|_| Error::from(too_large()))?;
let buf = zeroed(len)?;
let mut stats = WriteStats::new(size, Backing::Memory);
stats.record(WritePhase::Open, start.elapsed());
Ok(Self {
path: None,
storage: Storage::Buffer(buf),
destination: Destination::Memory,
options: OutputOptions::default(),
release: None,
stats,
})
}
#[must_use]
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
#[must_use]
pub fn len(&self) -> usize {
self.storage.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
#[must_use]
pub fn backing(&self) -> Backing {
self.stats.backing
}
pub fn as_slice(&mut self) -> Result<&[u8]> {
self.as_mut_slice().map(|image| &*image)
}
pub fn as_mut_slice(&mut self) -> Result<&mut [u8]> {
self.materialize()?;
match &mut self.storage {
Storage::Mapped(map) => Ok(map),
Storage::Buffer(buf) => Ok(buf),
Storage::Positional(_) => Err(Error::Internal("output image not in memory".into())),
}
}
fn materialize(&mut self) -> Result<()> {
let Storage::Positional(state) = &self.storage else {
return Ok(());
};
let path = self.path.as_deref();
let mut buf = zeroed(state.len).map_err(|e| io_error(path, e))?;
if state.written {
let file = destination_file(&self.destination).ok_or_else(no_file)?;
written::read_buffer(file, &mut buf, 0).map_err(|e| io_error(path, e))?;
}
self.storage = Storage::Buffer(buf);
Ok(())
}
pub fn write_at(&mut self, offset: u64, bytes: &[u8]) -> Result<()> {
let range = ChunkRange::new(offset, bytes.len() as u64);
let len = self.len() as u64;
let path = self.path.as_deref();
let out_of_bounds = || layout_error(path, LayoutError::FieldOutOfBounds { range, len });
let end = range
.end()
.filter(|end| *end <= len)
.ok_or_else(out_of_bounds)?;
match &mut self.storage {
Storage::Positional(state) => {
let file = destination_file(&self.destination).ok_or_else(no_file)?;
state.touch();
written::write_buffer(file, bytes, offset).map_err(|e| io_error(path, e))?;
}
Storage::Mapped(map) => {
copy_range(map, offset, end, bytes).ok_or_else(out_of_bounds)?
}
Storage::Buffer(buf) => {
copy_range(buf, offset, end, bytes).ok_or_else(out_of_bounds)?
}
}
Ok(())
}
#[must_use]
pub fn stats(&self) -> &WriteStats {
&self.stats
}
pub fn stats_mut(&mut self) -> &mut WriteStats {
&mut self.stats
}
pub fn split_chunks(&mut self, ranges: &[ChunkRange]) -> Result<Vec<&mut [u8]>> {
let path = self.path.clone();
chunks::validate_layout(ranges, self.len() as u64)
.map_err(|e| layout_error(path.as_deref(), e))?;
chunks::split_chunks(self.as_mut_slice()?, ranges)
.map_err(|e| layout_error(path.as_deref(), e))
}
pub fn write_chunks<F>(&mut self, ranges: &[ChunkRange], write: F) -> Result<()>
where
F: Fn(usize, &mut [u8]) -> Result<()> + Sync,
{
let start = Instant::now();
let path = self.path.as_deref();
let len = self.storage.len() as u64;
let cancel = self.options.cancel.clone();
let write = |index: usize, chunk: &mut [u8]| {
if let Some(token) = &cancel {
token.check()?;
}
write(index, chunk)
};
let result = chunks::validate_layout(ranges, len)
.map_err(|e| layout_error(path, e))
.and_then(|()| match &mut self.storage {
Storage::Positional(state) => {
let (Some(file), Some(path)) = (destination_file(&self.destination), path)
else {
return Err(no_file());
};
let plan = state.plan.as_ref().filter(|_| !state.written);
let outcome = written::write_chunks(file, path, len, ranges, write, plan);
state.touch();
state.precomputed = outcome?;
Ok(())
}
Storage::Mapped(map) => run_chunks(map, ranges, path, &write),
Storage::Buffer(buf) => run_chunks(buf, ranges, path, &write),
});
self.stats.record(WritePhase::Write, start.elapsed());
result
}
pub fn reserve_build_id(&mut self, kind: &BuildId, offset: u64) {
if let Storage::Positional(state) = &mut self.storage {
state.plan = BlockHasher::new(kind).map(|_| HashPlan {
kind: kind.clone(),
field: ChunkRange::new(offset, build_id_size(kind).unwrap_or(0) as u64),
});
}
}
pub fn apply_build_id(&mut self, kind: &BuildId, offset: u64) -> Result<Option<Vec<u8>>> {
let start = Instant::now();
let path = self.path.as_deref();
let result = match &mut self.storage {
Storage::Mapped(map) => {
build_id::apply_build_id(kind, map, offset).map_err(|e| layout_error(path, e))
}
Storage::Buffer(buf) => {
build_id::apply_build_id(kind, buf, offset).map_err(|e| layout_error(path, e))
}
Storage::Positional(_) => self.apply_build_id_positional(kind, offset),
};
self.stats.record(WritePhase::BuildId, start.elapsed());
result
}
fn apply_build_id_positional(
&mut self,
kind: &BuildId,
offset: u64,
) -> Result<Option<Vec<u8>>> {
let Some(size) = build_id_size(kind) else {
return Ok(None);
};
let len = self.storage.len() as u64;
let path = self.path.as_deref();
let field = ChunkRange::new(offset, size as u64);
if field.end().is_none_or(|end| end > len) {
return Err(layout_error(
path,
LayoutError::FieldOutOfBounds { range: field, len },
));
}
let (Storage::Positional(state), Some(file)) =
(&mut self.storage, destination_file(&self.destination))
else {
return Err(no_file());
};
let precomputed = state
.precomputed
.take()
.filter(|pre| pre.plan.kind == *kind && pre.plan.field == field);
let id = match written::finish_build_id(file, len, kind, field, precomputed)
.map_err(|e| io_error(path, e))?
{
Some(id) => id,
None => match build_id::compute_build_id(kind, &[]) {
Some(id) => id,
None => return Ok(None),
},
};
let mut patch = vec![0u8; size];
if let Some(dest) = patch.get_mut(..id.len()) {
dest.copy_from_slice(&id);
}
self.write_at(offset, &patch)?;
Ok(Some(id))
}
pub fn finish(mut self) -> Result<Finished> {
self.options.check_cancelled()?;
let start = Instant::now();
let storage = std::mem::replace(&mut self.storage, Storage::Buffer(Vec::new()));
let destination = std::mem::replace(&mut self.destination, Destination::Done);
let mut bytes = None;
let mut release = self.release.take();
match (destination, self.path.as_deref()) {
(Destination::Memory, _) | (_, None) => {
bytes = Some(match storage {
Storage::Buffer(buf) => buf,
Storage::Mapped(map) => map.to_vec(),
Storage::Positional(_) => Vec::new(),
});
}
(Destination::Done, Some(_)) => {}
(Destination::Stream { mut file }, Some(path)) => {
let image: &[u8] = match &storage {
Storage::Buffer(buf) => buf,
Storage::Mapped(map) => map,
Storage::Positional(_) => &[],
};
file.write_all(image)
.and_then(|()| file.flush())
.map_err(|e| Error::io(path, e))?;
}
(Destination::Direct { file }, Some(path)) => {
if let Err(e) = commit_file(&file, storage, &self.options) {
drop(file);
let _ = fs::remove_file(path);
return Err(Error::io(path, e));
}
}
(Destination::Temp { file, temp }, Some(path)) => {
let committed = commit_file(&file, storage, &self.options).and_then(|()| {
drop(file);
let old = hold_old_output(path, self.options.background_release_threshold);
if self.options.replace == ReplaceStrategy::Rename {
let _ = fs::remove_file(path);
}
fs::rename(&temp, path)?;
if let Some(old) = old {
release = release_in_background(old);
}
Ok(())
});
if let Err(e) = committed {
let _ = fs::remove_file(&temp);
return Err(Error::io(path, e));
}
}
}
if let Some(buffer) = &self.options.capture
&& let Some(image) = bytes.take()
{
buffer.store(image);
}
let mut stats = std::mem::take(&mut self.stats);
stats.background_release |= release.is_some();
stats.record(WritePhase::Commit, start.elapsed());
Ok(Finished {
bytes,
stats,
release,
})
}
}
impl Drop for OutputFile {
fn drop(&mut self) {
drop(std::mem::replace(
&mut self.storage,
Storage::Buffer(Vec::new()),
));
match std::mem::replace(&mut self.destination, Destination::Done) {
Destination::Temp { file, temp } => {
drop(file);
let _ = fs::remove_file(temp);
}
Destination::Direct { file } => {
drop(file);
if let Some(path) = &self.path {
let _ = fs::remove_file(path);
}
}
Destination::Stream { .. } | Destination::Memory | Destination::Done => {}
}
}
}
#[derive(Debug)]
pub struct Finished {
bytes: Option<Vec<u8>>,
stats: WriteStats,
release: Option<JoinHandle<()>>,
}
impl Finished {
#[must_use]
pub fn bytes(&self) -> Option<&[u8]> {
self.bytes.as_deref()
}
#[must_use]
pub fn into_bytes(self) -> Option<Vec<u8>> {
self.bytes
}
#[must_use]
pub fn stats(&self) -> &WriteStats {
&self.stats
}
pub fn wait_for_release(&mut self) {
if let Some(handle) = self.release.take() {
let _ = handle.join();
}
}
}
fn too_large() -> io::Error {
io::Error::new(
io::ErrorKind::FileTooLarge,
"output is larger than the address space",
)
}
fn copy_range(image: &mut [u8], offset: u64, end: u64, bytes: &[u8]) -> Option<()> {
let dest = image.get_mut(usize::try_from(offset).ok()?..usize::try_from(end).ok()?)?;
dest.copy_from_slice(bytes);
Some(())
}
fn run_chunks<F>(
image: &mut [u8],
ranges: &[ChunkRange],
path: Option<&Path>,
write: &F,
) -> Result<()>
where
F: Fn(usize, &mut [u8]) -> Result<()> + Sync,
{
use rayon::prelude::*;
let slices = chunks::split_chunks(image, ranges).map_err(|e| layout_error(path, e))?;
let results: Vec<Result<()>> = slices
.into_par_iter()
.enumerate()
.map(|(index, chunk)| write(index, chunk))
.collect();
results.into_iter().collect()
}
fn layout_error(path: Option<&Path>, error: LayoutError) -> Error {
match path {
Some(path) => Error::Internal(format!(
"{}: invalid output layout: {error}",
path.display()
)),
None => Error::Internal(format!("invalid output layout: {error}")),
}
}
fn zeroed(len: usize) -> io::Result<Vec<u8>> {
let mut buf = Vec::new();
buf.try_reserve_exact(len)
.map_err(|e| io::Error::new(io::ErrorKind::OutOfMemory, e))?;
buf.resize(len, 0);
Ok(buf)
}
fn open_options(mode: FileMode) -> OpenOptions {
let mut options = OpenOptions::new();
options.read(true).write(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(match mode {
FileMode::Executable => 0o777,
FileMode::Regular => 0o666,
FileMode::Exact(_) => 0o600,
});
}
#[cfg(not(unix))]
let _ = mode;
options
}
fn is_system_path(path: &Path) -> bool {
cfg!(unix) && (path.starts_with("/dev") || path.starts_with("/proc"))
}
fn create_temp(path: &Path, mode: FileMode) -> io::Result<(File, PathBuf)> {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let name = path.file_name().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "output path has no file name")
})?;
let dir = match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent,
_ => Path::new("."),
};
let mut last_error = None;
for _ in 0..16 {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let mut temp_name = OsString::from(".");
temp_name.push(name);
temp_name.push(format!(".qld-{}-{n}.tmp", std::process::id()));
let temp = dir.join(temp_name);
let mut options = open_options(mode);
options.create_new(true);
match options.open(&temp) {
Ok(file) => return Ok((file, temp)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last_error = Some(e),
Err(e) => return Err(e),
}
}
Err(last_error.unwrap_or_else(|| io::Error::from(io::ErrorKind::AlreadyExists)))
}
fn commit_file(file: &File, storage: Storage, options: &OutputOptions) -> io::Result<()> {
match storage {
Storage::Mapped(map) => {
if options.sync {
map.flush()?;
}
drop(map);
}
Storage::Buffer(buf) => {
if positional::SUPPORTED {
written::write_buffer(file, &buf, 0)?;
} else {
let mut writer = file;
writer.write_all(&buf)?;
}
}
Storage::Positional(_) => {}
}
#[cfg(unix)]
if let FileMode::Exact(mode) = options.mode {
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(mode))?;
}
if options.sync {
file.sync_all()?;
}
Ok(())
}
#[cfg(unix)]
fn hold_old_output(path: &Path, threshold: Option<u64>) -> Option<File> {
use std::os::unix::fs::MetadataExt;
let threshold = threshold?;
let meta = fs::symlink_metadata(path).ok()?;
if !meta.is_file() || meta.len() < threshold || meta.nlink() != 1 {
return None;
}
File::open(path).ok()
}
#[cfg(not(unix))]
fn hold_old_output(_path: &Path, _threshold: Option<u64>) -> Option<File> {
None
}
fn release_in_background(file: File) -> Option<JoinHandle<()>> {
std::thread::Builder::new()
.name("qld-release-output".into())
.spawn(move || drop(file))
.ok()
}