#![forbid(unsafe_code)]
//! Platform-neutral capability contracts for Flash.
//!
//! Host adapters and the future FlashOS adapter implement [`Platform`] behind
//! this boundary. The engine never assumes a POSIX host: it asks a platform
//! which capabilities it supports and receives a precise [`PlatformError`] when
//! a capability is absent, rather than silently emulating unsafe behaviour.
//!
//! Platform calls are synchronous and blocking; concurrency (for example
//! draining a pipe while a child runs) is arranged by the caller with threads,
//! not by an async runtime. Byte-preserving data crosses the boundary as the
//! standard [`std::ffi::OsStr`] / [`std::path::Path`] family so native argv,
//! environment, and path bytes survive without lossy UTF-8 conversion.
use std::any::Any;
use std::ffi::{OsStr, OsString};
use std::fmt;
use std::io;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
/// One platform capability group an adapter either supports or does not.
///
/// A platform that cannot honour a capability (for example a bare-metal target
/// without process groups) reports it as unsupported, and the runtime turns the
/// resulting [`PlatformError::Unsupported`] into a precise feature diagnostic.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Capability {
/// Environment snapshot and per-child mutation.
Environment,
/// Working directory and path resolution.
WorkingDirectory,
/// File open, duplication, and close actions on child descriptors.
FileActions,
/// Anonymous pipe creation for pipeline plumbing.
Pipes,
/// Direct argv process spawn, exec, and wait.
ProcessSpawn,
/// Process-group creation and membership.
ProcessGroups,
/// Foreground terminal ownership handoff.
ForegroundTerminal,
/// Signal delivery and cancellation.
Signals,
/// Terminal size, TTY detection, and canonical/raw input mode.
TerminalInfo,
/// A monotonic clock source.
MonotonicClock,
/// Home, config, and cache directory discovery.
StandardDirectories,
/// Directory entry enumeration.
///
/// Deliberately separate from [`FileActions`](Capability::FileActions),
/// which covers opening, duplicating, and closing child descriptors —
/// plumbing, not enumeration. A capability is supported or it is not, with
/// no partial support, so an adapter that can open a file but cannot list a
/// directory needs its own bit to say so.
DirectoryRead,
/// The path of the running shell executable, for re-execution.
ShellExecutable,
/// Installing this process's own hang-up disposition.
HangupDisposition,
}
impl Capability {
/// Every capability, in declaration order.
pub const ALL: [Capability; 14] = [
Capability::Environment,
Capability::WorkingDirectory,
Capability::FileActions,
Capability::Pipes,
Capability::ProcessSpawn,
Capability::ProcessGroups,
Capability::ForegroundTerminal,
Capability::Signals,
Capability::TerminalInfo,
Capability::MonotonicClock,
Capability::StandardDirectories,
Capability::DirectoryRead,
Capability::ShellExecutable,
Capability::HangupDisposition,
];
/// The single set bit that represents this capability.
const fn bit(self) -> u16 {
1 << (self as u16)
}
}
/// A set of supported [`Capability`] values.
///
/// Backed by a fixed-width bitset so it is `Copy`, allocation-free, and usable
/// in `const` contexts — the shape the future bare-metal FlashOS adapter needs.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct Capabilities {
bits: u16,
}
impl Capabilities {
/// The empty set — nothing supported.
pub const fn empty() -> Self {
Self { bits: 0 }
}
/// The full set — every capability supported.
pub const fn full() -> Self {
let mut bits = 0u16;
let mut index = 0;
while index < Capability::ALL.len() {
bits |= Capability::ALL[index].bit();
index += 1;
}
Self { bits }
}
/// The full set minus `capability`.
///
/// A host that provides everything except one capability is the shape worth
/// naming directly: spelling it as a list of every other group would drift
/// silently the next time a capability is added.
#[must_use]
pub const fn full_without(capability: Capability) -> Self {
Self {
bits: Self::full().bits & !capability.bit(),
}
}
/// This set with `capability` added; adding a present capability is a no-op.
#[must_use]
pub const fn with(self, capability: Capability) -> Self {
Self {
bits: self.bits | capability.bit(),
}
}
/// Whether `capability` is in the set.
pub const fn supports(self, capability: Capability) -> bool {
self.bits & capability.bit() != 0
}
}
/// A platform capability that failed to be satisfied.
///
/// [`Unsupported`](PlatformError::Unsupported) is a permanent gap: the platform
/// can never provide the capability, and the runtime reports a feature
/// diagnostic. [`Unavailable`](PlatformError::Unavailable) is transient: the
/// capability exists in principle but cannot be used right now (for example a
/// clock source that has not started), and carries a human-readable reason.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlatformError {
/// The platform does not provide `capability` at all.
Unsupported {
/// The capability that is permanently absent.
capability: Capability,
},
/// The platform provides `capability` but it cannot be used right now.
Unavailable {
/// The capability that is temporarily unusable.
capability: Capability,
/// A human-readable reason the capability is unavailable.
reason: String,
},
}
impl fmt::Display for PlatformError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PlatformError::Unsupported { capability } => {
write!(
formatter,
"platform capability {capability:?} is not supported"
)
}
PlatformError::Unavailable { capability, reason } => write!(
formatter,
"platform capability {capability:?} is unavailable: {reason}",
),
}
}
}
impl std::error::Error for PlatformError {}
/// Native environment lookup used by platform-owned directory policy.
///
/// The caller supplies the process or session environment, while each adapter
/// owns the variable names, validation, and fallback convention it understands.
/// This keeps XDG, macOS, and FlashOS policy out of portable runtime code.
pub trait StandardDirectoryEnvironment {
/// Return one native environment value without lossy text conversion.
fn value(&self, name: &OsStr) -> Option<OsString>;
}
/// The native per-user roots selected by one platform adapter.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StandardDirectories {
home: PathBuf,
config: PathBuf,
cache: PathBuf,
state: PathBuf,
}
impl StandardDirectories {
/// Build one complete standard-directory selection.
#[must_use]
pub fn new(home: PathBuf, config: PathBuf, cache: PathBuf, state: PathBuf) -> Self {
Self {
home,
config,
cache,
state,
}
}
/// The selected home directory.
#[must_use]
pub fn home(&self) -> &Path {
&self.home
}
/// The selected per-user configuration root.
#[must_use]
pub fn config(&self) -> &Path {
&self.config
}
/// The selected per-user cache root.
#[must_use]
pub fn cache(&self) -> &Path {
&self.cache
}
/// The selected per-user persistent state root.
#[must_use]
pub fn state(&self) -> &Path {
&self.state
}
}
/// One uniquely owned descriptor resource returned by a platform adapter.
///
/// Child descriptor maps borrow endpoints opaquely. An anonymous-pipe endpoint
/// may additionally be pulled or pushed by the in-process pipeline bridge; file
/// endpoints used by internal commands remain represented by [`FileIoEndpoint`].
pub trait DescriptorEndpoint: Send + fmt::Debug {
/// Adapter-private concrete endpoint access for the matching spawn adapter.
fn as_any(&self) -> &dyn Any;
/// Read at most `buffer.len()` bytes from an owned pipe reader.
fn read(&mut self, _buffer: &mut [u8]) -> Result<usize, DescriptorReadError> {
Err(DescriptorReadError::InvalidEndpoint)
}
/// Write bytes to an owned pipe writer.
fn write(&mut self, _buffer: &[u8]) -> Result<usize, DescriptorWriteError> {
Err(DescriptorWriteError::InvalidEndpoint)
}
}
/// The two uniquely owned endpoints of one anonymous byte pipe.
#[derive(Debug)]
pub struct PipeEndpoints {
reader: Box<dyn DescriptorEndpoint>,
writer: Box<dyn DescriptorEndpoint>,
}
impl PipeEndpoints {
/// Build one pipe from its read and write endpoint owners.
pub fn new(reader: Box<dyn DescriptorEndpoint>, writer: Box<dyn DescriptorEndpoint>) -> Self {
Self { reader, writer }
}
/// Split the pipe into its unique read and write endpoint owners.
pub fn into_parts(self) -> (Box<dyn DescriptorEndpoint>, Box<dyn DescriptorEndpoint>) {
(self.reader, self.writer)
}
}
/// Failure while creating an anonymous byte pipe.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PipeError {
/// The platform cannot satisfy the pipe capability.
Platform(PlatformError),
/// The host rejected or could not complete pipe creation.
Operation {
/// Stable I/O error category from the host adapter.
kind: io::ErrorKind,
/// Human-readable host error text.
message: String,
},
}
impl fmt::Display for PipeError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Platform(error) => error.fmt(formatter),
Self::Operation { message, .. } => write!(formatter, "pipe creation failed: {message}"),
}
}
}
impl std::error::Error for PipeError {}
impl From<PlatformError> for PipeError {
fn from(error: PlatformError) -> Self {
Self::Platform(error)
}
}
/// Failure while draining bytes from an owned descriptor endpoint.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DescriptorReadError {
/// The platform cannot satisfy pipe-backed descriptor reads.
Platform(PlatformError),
/// The endpoint was not created by the adapter asked to read it.
InvalidEndpoint,
/// The host read operation failed.
Operation {
/// Stable I/O error category from the host adapter.
kind: io::ErrorKind,
/// Human-readable host error text.
message: String,
},
}
impl fmt::Display for DescriptorReadError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Platform(error) => error.fmt(formatter),
Self::InvalidEndpoint => {
formatter.write_str("descriptor endpoint belongs to another platform adapter")
}
Self::Operation { message, .. } => {
write!(formatter, "descriptor read failed: {message}")
}
}
}
}
impl std::error::Error for DescriptorReadError {}
impl From<PlatformError> for DescriptorReadError {
fn from(error: PlatformError) -> Self {
Self::Platform(error)
}
}
/// Failure while feeding bytes through an owned descriptor endpoint.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DescriptorWriteError {
/// The platform cannot satisfy pipe-backed descriptor writes.
Platform(PlatformError),
/// The endpoint was not created as a writable pipe endpoint.
InvalidEndpoint,
/// The host write operation failed.
Operation {
/// Stable I/O error category from the host adapter.
kind: io::ErrorKind,
/// Human-readable host error text.
message: String,
},
}
impl fmt::Display for DescriptorWriteError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Platform(error) => error.fmt(formatter),
Self::InvalidEndpoint => {
formatter.write_str("descriptor endpoint is not a writable pipe endpoint")
}
Self::Operation { message, .. } => {
write!(formatter, "descriptor write failed: {message}")
}
}
}
}
impl std::error::Error for DescriptorWriteError {}
impl From<PlatformError> for DescriptorWriteError {
fn from(error: PlatformError) -> Self {
Self::Platform(error)
}
}
/// One byte-preserving logical working-directory resolution request.
#[derive(Clone, Copy, Debug)]
pub struct WorkingDirectoryRequest<'a> {
path: &'a Path,
cwd: &'a Path,
}
impl<'a> WorkingDirectoryRequest<'a> {
/// Resolve `path`, interpreting a relative path against `cwd`.
pub const fn new(path: &'a Path, cwd: &'a Path) -> Self {
Self { path, cwd }
}
/// The requested native path.
pub const fn path(self) -> &'a Path {
self.path
}
/// The current logical working directory.
pub const fn cwd(self) -> &'a Path {
self.cwd
}
}
/// Failure while resolving and validating a logical working directory.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WorkingDirectoryError {
/// The platform cannot satisfy working-directory resolution.
Platform(PlatformError),
/// The host rejected the path or it did not name a directory.
Operation {
/// Stable I/O error category from the host adapter.
kind: io::ErrorKind,
/// Human-readable host error text.
message: String,
},
}
impl fmt::Display for WorkingDirectoryError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Platform(error) => error.fmt(formatter),
Self::Operation { message, .. } => {
write!(formatter, "working-directory resolution failed: {message}")
}
}
}
}
impl std::error::Error for WorkingDirectoryError {}
impl From<PlatformError> for WorkingDirectoryError {
fn from(error: PlatformError) -> Self {
Self::Platform(error)
}
}
/// How a redirection target is opened for one child descriptor.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileOpenMode {
/// Open an existing file for reading.
Read,
/// Create or replace a file for writing.
WriteTruncate,
/// Create or append to a file for writing.
WriteAppend,
}
/// One byte-preserving file-open request for redirection setup.
#[derive(Clone, Copy, Debug)]
pub struct FileOpenRequest<'a> {
path: &'a Path,
cwd: &'a Path,
mode: FileOpenMode,
}
impl<'a> FileOpenRequest<'a> {
/// Build a file-open request relative to the stage working directory.
pub const fn new(path: &'a Path, cwd: &'a Path, mode: FileOpenMode) -> Self {
Self { path, cwd, mode }
}
/// The native redirection target.
pub const fn path(self) -> &'a Path {
self.path
}
/// The working directory used for a relative target.
pub const fn cwd(self) -> &'a Path {
self.cwd
}
/// The requested read, truncate, or append semantics.
pub const fn mode(self) -> FileOpenMode {
self.mode
}
}
/// Failure while opening or transferring through an owned file endpoint.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileActionError {
/// The platform cannot satisfy file actions.
Platform(PlatformError),
/// The host rejected an open or inherited-descriptor duplication.
Operation {
/// Stable I/O error category from the host adapter.
kind: io::ErrorKind,
/// Human-readable host error text.
message: String,
},
}
impl fmt::Display for FileActionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Platform(error) => error.fmt(formatter),
Self::Operation { message, .. } => {
write!(formatter, "file action failed: {message}")
}
}
}
}
impl std::error::Error for FileActionError {}
impl From<PlatformError> for FileActionError {
fn from(error: PlatformError) -> Self {
Self::Platform(error)
}
}
/// One owned file endpoint that can outlive command dispatch.
///
/// Redirection endpoints remain opaque because the spawn adapter installs
/// them into a child descriptor map. `open` and `save` instead need an owned
/// object they can read or write lazily after the platform call returns.
pub trait FileIoEndpoint: Send + fmt::Debug {
/// Read at most `buffer.len()` bytes, returning zero at EOF.
fn read(&mut self, buffer: &mut [u8]) -> Result<usize, FileActionError>;
/// Write bytes from `buffer`, returning the number accepted.
fn write(&mut self, buffer: &[u8]) -> Result<usize, FileActionError>;
}
/// One byte-preserving directory-enumeration request.
#[derive(Clone, Copy, Debug)]
pub struct DirectoryReadRequest<'a> {
path: &'a Path,
cwd: &'a Path,
}
impl<'a> DirectoryReadRequest<'a> {
/// Build an enumeration request relative to the stage working directory.
pub const fn new(path: &'a Path, cwd: &'a Path) -> Self {
Self { path, cwd }
}
/// The native directory to enumerate.
pub const fn path(self) -> &'a Path {
self.path
}
/// The working directory used for a relative target.
pub const fn cwd(self) -> &'a Path {
self.cwd
}
}
/// What one directory entry is, without following a symbolic link.
///
/// A link reports itself as a link rather than as its target: resolving it
/// would be a second host access the caller did not ask for, and a dangling
/// link has no target to report at all.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DirectoryEntryKind {
/// A regular file.
File,
/// A directory.
Directory,
/// A symbolic link, unresolved.
Symlink,
/// Anything else the host distinguishes but the shell does not.
Other,
}
/// One entry of an enumerated directory.
///
/// The name is the bare entry name, not a joined path, and stays an
/// [`OsString`] so native bytes survive without a lossy conversion.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DirectoryEntry {
name: OsString,
kind: DirectoryEntryKind,
size: Option<u64>,
}
impl DirectoryEntry {
/// Build one entry from its native name, kind, and regular-file byte length.
///
/// `size` is `None` for everything that is not a regular file: a directory
/// or a link has no byte length the shell could report without inventing
/// one.
pub const fn new(name: OsString, kind: DirectoryEntryKind, size: Option<u64>) -> Self {
Self { name, kind, size }
}
/// The bare native entry name.
pub fn name(&self) -> &OsStr {
&self.name
}
/// What this entry is, with links left unresolved.
pub const fn kind(&self) -> DirectoryEntryKind {
self.kind
}
/// The byte length of a regular file, else `None`.
pub const fn size(&self) -> Option<u64> {
self.size
}
}
/// Failure while enumerating a directory.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DirectoryReadError {
/// The platform cannot satisfy directory reads.
Platform(PlatformError),
/// The host rejected the enumeration or failed mid-walk.
Operation {
/// Stable I/O error category from the host adapter.
kind: io::ErrorKind,
/// Human-readable host error text.
message: String,
},
}
impl fmt::Display for DirectoryReadError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Platform(error) => error.fmt(formatter),
Self::Operation { message, .. } => {
write!(formatter, "directory read failed: {message}")
}
}
}
}
impl std::error::Error for DirectoryReadError {}
impl From<PlatformError> for DirectoryReadError {
fn from(error: PlatformError) -> Self {
Self::Platform(error)
}
}
/// An owned, pull-driven walk over one directory's entries.
///
/// Enumeration is lazy rather than a materialized vector so a structured
/// pipeline that stops early — `ls | first 1` — stops the host walk with it,
/// which is what the milestone's no-full-materialization rule requires.
/// Exhaustion is `Ok(None)` and is idempotent.
pub trait DirectoryStream: Send + fmt::Debug {
/// Advance the walk by one entry; `Ok(None)` is exhaustion.
fn next_entry(&mut self) -> Result<Option<DirectoryEntry>, DirectoryReadError>;
}
/// One entry in the final logical descriptor map passed to a child.
#[derive(Clone, Copy, Debug)]
pub struct ChildDescriptor<'a> {
target: u32,
endpoint: &'a dyn DescriptorEndpoint,
}
impl<'a> ChildDescriptor<'a> {
/// Map `endpoint` onto the child's descriptor number `target`.
pub const fn new(target: u32, endpoint: &'a dyn DescriptorEndpoint) -> Self {
Self { target, endpoint }
}
/// The descriptor number visible in the child.
pub const fn target(self) -> u32 {
self.target
}
/// The opaque owned resource borrowed for this spawn.
pub const fn endpoint(self) -> &'a dyn DescriptorEndpoint {
self.endpoint
}
}
/// An adapter-native process-group identifier, widened for a portable boundary.
///
/// Zero is rejected because POSIX spells "the calling process" as `0` in
/// `setpgid`; a group identifier that could be zero would make "join this
/// existing group" and "become a new leader" indistinguishable at the seam.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ProcessGroupId(u64);
impl ProcessGroupId {
/// Build an identifier, rejecting the reserved zero value.
pub const fn new(value: u64) -> Option<Self> {
if value == 0 { None } else { Some(Self(value)) }
}
/// The adapter-native identifier.
pub const fn get(self) -> u64 {
self.0
}
}
impl fmt::Display for ProcessGroupId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.0)
}
}
/// Where a spawned child's process group comes from.
///
/// The runtime places every member of one pipeline in a single group so a later
/// slice can signal, stop, and hand the terminal to the job as a unit. Group
/// placement is decided before the first spawn and never changes afterwards.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum ProcessGroup {
/// Stay in the shell's own group, as an unmanaged child.
#[default]
Inherit,
/// Become the leader of a new group named after the child itself.
New,
/// Join an existing group established by an earlier pipeline member.
Join(ProcessGroupId),
}
impl ProcessGroup {
/// Whether honouring this placement needs [`Capability::ProcessGroups`].
pub const fn requires_capability(self) -> bool {
!matches!(self, ProcessGroup::Inherit)
}
}
/// One signal the shell deliberately sends to a job's process group.
///
/// A closed set rather than a raw number: the shell only ever chooses from
/// signals it must be able to honour on every target, and a platform-independent
/// caller must not name a host signal number it did not receive from an adapter.
/// Inbound observations keep their raw number, because they report what the host
/// already did and must not be narrowed to what the shell happens to model.
///
/// `Stop` and the termination signals become runtime callers when the `kill`
/// built-in lands; the shell resumes and hangs up stopped jobs before then.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum JobSignal {
/// Stop the group unconditionally.
Stop,
/// Resume a stopped group.
Continue,
/// Hang up the group because its session is ending.
///
/// Sent only after the shell has resumed the group, because a stopped
/// process cannot act on a hang-up it never receives.
Hangup,
/// Interrupt the group as if requested from its controlling terminal.
Interrupt,
/// Ask the group to terminate cleanly.
Terminate,
/// Terminate the group unconditionally.
Kill,
}
/// Failure while delivering a signal to a process group.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SignalError {
/// The platform cannot satisfy the signal capability.
Platform(PlatformError),
/// The host rejected or could not complete the delivery.
Operation {
/// Stable I/O error category from the host adapter.
kind: io::ErrorKind,
/// A human-readable description of the host failure.
message: String,
},
}
impl From<PlatformError> for SignalError {
fn from(error: PlatformError) -> Self {
Self::Platform(error)
}
}
impl fmt::Display for SignalError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Platform(error) => error.fmt(formatter),
Self::Operation { message, .. } => {
write!(formatter, "signal delivery failed: {message}")
}
}
}
}
impl std::error::Error for SignalError {}
/// An active job-control signal arrangement owned by an interactive shell.
///
/// The dispositions are process-wide, so restoration must also run in the
/// implementor's `Drop`: an early exit or a panic would otherwise leave a
/// process whose interrupts are permanently ignored. `restore` is idempotent, so
/// an explicit call followed by the drop is well defined.
pub trait JobControlSignalGuard: fmt::Debug {
/// Put the previous dispositions back now, before the guard drops.
fn restore(&mut self) -> Result<(), PlatformError>;
}
/// A guard that arranged nothing; restoring it always succeeds.
#[derive(Debug)]
pub struct NoopJobControlSignalGuard;
impl JobControlSignalGuard for NoopJobControlSignalGuard {
fn restore(&mut self) -> Result<(), PlatformError> {
Ok(())
}
}
/// A structurally valid request to execute one program directly with argv.
///
/// The first argv entry is explicit rather than inferred from `executable`.
/// Environment entries describe the complete child environment, not a delta;
/// adapters clear their inherited environment before installing these entries.
#[derive(Clone, Copy, Debug)]
pub struct SpawnRequest<'a> {
executable: &'a Path,
argv: &'a [OsString],
environment: &'a [(OsString, OsString)],
cwd: &'a Path,
descriptors: &'a [ChildDescriptor<'a>],
closed_descriptors: &'a [u32],
process_group: ProcessGroup,
}
impl<'a> SpawnRequest<'a> {
/// Build a direct-spawn request, rejecting an absent argv zero.
pub fn new(
executable: &'a Path,
argv: &'a [OsString],
environment: &'a [(OsString, OsString)],
cwd: &'a Path,
) -> Result<Self, SpawnRequestError> {
if argv.is_empty() {
return Err(SpawnRequestError::EmptyArgv);
}
Ok(Self {
executable,
argv,
environment,
cwd,
descriptors: &[],
closed_descriptors: &[],
process_group: ProcessGroup::Inherit,
})
}
/// Place the child in `process_group` instead of the shell's own group.
#[must_use]
pub const fn in_process_group(mut self, process_group: ProcessGroup) -> Self {
self.process_group = process_group;
self
}
/// Attach the final child descriptor mappings to this request.
///
/// A target may occur only once. Two targets may deliberately borrow the
/// same endpoint, as required for a merged stdout-and-stderr pipeline.
pub fn with_descriptors(
mut self,
descriptors: &'a [ChildDescriptor<'a>],
) -> Result<Self, SpawnRequestError> {
for (index, mapping) in descriptors.iter().enumerate() {
if descriptors[..index]
.iter()
.any(|prior| prior.target == mapping.target)
{
return Err(SpawnRequestError::DuplicateDescriptor(mapping.target));
}
if self.closed_descriptors.contains(&mapping.target) {
return Err(SpawnRequestError::MappedAndClosedDescriptor(mapping.target));
}
}
self.descriptors = descriptors;
Ok(self)
}
/// Attach descriptor numbers that must be closed in the child.
pub fn with_closed_descriptors(
mut self,
closed_descriptors: &'a [u32],
) -> Result<Self, SpawnRequestError> {
for (index, descriptor) in closed_descriptors.iter().enumerate() {
if closed_descriptors[..index].contains(descriptor) {
return Err(SpawnRequestError::DuplicateClosedDescriptor(*descriptor));
}
if self
.descriptors
.iter()
.any(|mapping| mapping.target == *descriptor)
{
return Err(SpawnRequestError::MappedAndClosedDescriptor(*descriptor));
}
}
self.closed_descriptors = closed_descriptors;
Ok(self)
}
/// The resolved executable path passed directly to the host.
pub const fn executable(self) -> &'a Path {
self.executable
}
/// The complete native argv, including explicit argv zero.
pub const fn argv(self) -> &'a [OsString] {
self.argv
}
/// The complete native child environment.
pub const fn environment(self) -> &'a [(OsString, OsString)] {
self.environment
}
/// The child's working directory.
pub const fn cwd(self) -> &'a Path {
self.cwd
}
/// The final logical descriptors installed for this child.
pub const fn descriptors(self) -> &'a [ChildDescriptor<'a>] {
self.descriptors
}
/// The child descriptor numbers explicitly closed before execution.
pub const fn closed_descriptors(self) -> &'a [u32] {
self.closed_descriptors
}
/// The group placement the adapter must establish before execution.
pub const fn process_group(self) -> ProcessGroup {
self.process_group
}
}
/// A spawn request that cannot represent a process invocation.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SpawnRequestError {
/// Direct process execution requires an explicit argv zero.
EmptyArgv,
/// A final child descriptor map named the same target twice.
DuplicateDescriptor(u32),
/// A descriptor was named more than once in the final close set.
DuplicateClosedDescriptor(u32),
/// A descriptor was both mapped and closed in the final request.
MappedAndClosedDescriptor(u32),
}
impl fmt::Display for SpawnRequestError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyArgv => formatter.write_str("a spawn request requires argv zero"),
Self::DuplicateDescriptor(descriptor) => {
write!(
formatter,
"child descriptor {descriptor} is mapped more than once"
)
}
Self::DuplicateClosedDescriptor(descriptor) => {
write!(
formatter,
"child descriptor {descriptor} is closed more than once"
)
}
Self::MappedAndClosedDescriptor(descriptor) => {
write!(
formatter,
"child descriptor {descriptor} is both mapped and closed"
)
}
}
}
}
impl std::error::Error for SpawnRequestError {}
/// A direct process spawn failure.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SpawnError {
/// The platform cannot satisfy the process-spawn capability.
Platform(PlatformError),
/// The host rejected or could not complete the spawn operation.
Operation {
/// Stable I/O error category from the host adapter.
kind: io::ErrorKind,
/// Human-readable host error text.
message: String,
},
}
impl fmt::Display for SpawnError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Platform(error) => error.fmt(formatter),
Self::Operation { message, .. } => write!(formatter, "process spawn failed: {message}"),
}
}
}
impl std::error::Error for SpawnError {}
impl From<PlatformError> for SpawnError {
fn from(error: PlatformError) -> Self {
Self::Platform(error)
}
}
/// The low-level completion state of one child process.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProcessStatus {
/// The process called an exit path with this code.
Exited(i32),
/// The process was terminated by this platform signal number.
Signaled(i32),
}
/// What a blocking observation of one child reported.
///
/// Kept separate from [`ProcessStatus`], which is a completion type: a stopped
/// child has not completed, and folding a non-completion state into that enum
/// would force every site that reasons about a finished process to account for
/// one that is still alive.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProcessTransition {
/// The child stopped under this platform signal number and can be resumed.
Stopped {
/// The platform signal number that stopped the child.
signal: i32,
},
/// The child continued after a job-control stop.
Continued,
/// The child reached a terminal status, which the platform has consumed.
Completed(ProcessStatus),
}
/// Failure while waiting for an already-spawned child.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WaitError {
kind: io::ErrorKind,
message: String,
}
impl WaitError {
/// Build a wait error from a host I/O failure.
pub fn new(kind: io::ErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// Stable I/O error category from the host adapter.
pub const fn kind(&self) -> io::ErrorKind {
self.kind
}
}
impl fmt::Display for WaitError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "process wait failed: {}", self.message)
}
}
impl std::error::Error for WaitError {}
/// Failure while terminating a child during execution cleanup.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TerminateError {
kind: io::ErrorKind,
message: String,
}
impl TerminateError {
/// Build a termination error from a host I/O failure.
pub fn new(kind: io::ErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// Stable I/O error category from the host adapter.
pub const fn kind(&self) -> io::ErrorKind {
self.kind
}
}
impl fmt::Display for TerminateError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "process termination failed: {}", self.message)
}
}
impl std::error::Error for TerminateError {}
/// One owned child process returned by a platform adapter.
pub trait ChildProcess: Send + fmt::Debug {
/// Adapter-native process identifier, widened for a portable boundary.
fn id(&self) -> u64;
/// The group this child was actually placed in, when it has one.
///
/// Reported by the adapter rather than assumed by the caller: a child
/// spawned as [`ProcessGroup::New`] leads a group the caller cannot name in
/// advance, and an adapter without process groups reports `None`.
fn process_group(&self) -> Option<ProcessGroupId> {
None
}
/// Block until the child completes and return its low-level status.
///
/// Calling this more than once returns the same completed status.
fn wait(&mut self) -> Result<ProcessStatus, WaitError>;
/// Block until the child completes or stops, reporting which happened.
///
/// Defaulted to delegate to [`wait`](ChildProcess::wait), which never
/// reports a stop. An adapter that cannot observe a stop is also one that
/// cannot deliver one, so the default is correct rather than lossy.
fn wait_for_transition(&mut self) -> Result<ProcessTransition, WaitError> {
self.wait().map(ProcessTransition::Completed)
}
/// Observe one available child transition without blocking.
///
/// `None` means the child remains live and no transition is currently
/// available. Adapters that support interactive background jobs override
/// this method; the default reports the operation as unavailable rather
/// than turning a blocking wait into implicit polling.
fn try_wait_for_transition(&mut self) -> Result<Option<ProcessTransition>, WaitError> {
Err(WaitError::new(
io::ErrorKind::Unsupported,
"nonblocking child observation is not supported",
))
}
/// Request immediate termination during failure cleanup.
fn terminate(&mut self) -> Result<(), TerminateError>;
}
/// The character-cell dimensions of a terminal.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerminalSize {
columns: u16,
rows: u16,
}
impl TerminalSize {
#[must_use]
pub const fn new(columns: u16, rows: u16) -> Self {
Self { columns, rows }
}
#[must_use]
pub const fn columns(&self) -> u16 {
self.columns
}
#[must_use]
pub const fn rows(&self) -> u16 {
self.rows
}
}
/// An active raw-mode acquisition that restores the saved attributes on drop.
///
/// Restoration must also run in the implementor's `Drop`, so a panic or an
/// early `exit` cannot leave the console unusable. `restore` is idempotent so
/// an explicit call followed by the drop is well defined.
pub trait TerminalModeGuard: fmt::Debug {
/// Restore the saved terminal attributes now, before the guard drops.
fn restore(&mut self) -> Result<(), PlatformError>;
}
/// Opaque portable snapshot of terminal attributes retained with a stopped job.
pub trait TerminalModeToken: Send + fmt::Debug {
/// Adapter-private concrete token access.
fn as_any(&self) -> &dyn Any;
}
/// An active handover of the terminal to a foreground job.
///
/// The shell must get the terminal back whatever happens to the job, so
/// restoration also runs in the implementor's `Drop`: a runtime error, an early
/// return, or a panic during the wait would otherwise leave the terminal owned
/// by a process that has exited, where the next read raises `SIGTTIN` instead of
/// reaching the prompt. `restore` is idempotent, so an explicit call followed by
/// the drop is well defined.
pub trait ForegroundTerminalGuard: fmt::Debug {
/// Return terminal ownership to the shell now, before the guard drops.
fn restore(&mut self) -> Result<(), PlatformError>;
/// The group that held the terminal when this handover began.
fn previous_owner(&self) -> Option<ProcessGroupId>;
}
/// Implemented by Flash platform adapters.
///
/// Capability methods (spawn, pipes, file actions, …) are added to this trait
/// as the features that need them are built; the foundation is the capability
/// query plus the [`require`](Platform::require) guard every capability method
/// calls before touching the host.
pub trait Platform: Send + Sync {
/// The capabilities this platform supports.
fn capabilities(&self) -> Capabilities;
/// Return `Ok(())` when `capability` is supported, else
/// [`PlatformError::Unsupported`] naming it.
fn require(&self, capability: Capability) -> Result<(), PlatformError> {
if self.capabilities().supports(capability) {
Ok(())
} else {
Err(PlatformError::Unsupported { capability })
}
}
/// Whether the standard input of this process is a terminal.
fn is_terminal(&self) -> bool {
false
}
/// Whether the standard output of this process is a terminal.
///
/// Deliberately separate from [`Platform::is_terminal`]: a session can be
/// typed at from a keyboard while its output is redirected, and an editor
/// that redraws with cursor escapes must not write them into the redirect
/// target.
fn is_output_terminal(&self) -> bool {
false
}
/// The current terminal dimensions.
fn terminal_size(&self) -> Result<TerminalSize, PlatformError> {
self.require(Capability::TerminalInfo)?;
Ok(TerminalSize::new(80, 24))
}
/// Put the terminal into raw mode until the returned guard is dropped.
fn enter_raw_mode(&self) -> Result<Box<dyn TerminalModeGuard>, PlatformError> {
self.require(Capability::TerminalInfo)?;
Ok(Box::new(NoopTerminalModeGuard))
}
/// Read standard-terminal input, waiting at most `timeout` for bytes.
///
/// `None` means the timeout elapsed, while `Some(0)` is end of input.
/// The default keeps synthetic and non-terminal adapters on their supplied
/// reader by reporting that this direct terminal route is unsupported.
fn read_terminal_input(
&self,
_buffer: &mut [u8],
_timeout: Duration,
) -> Result<Option<usize>, PlatformError> {
self.require(Capability::TerminalInfo)?;
Err(PlatformError::Unsupported {
capability: Capability::TerminalInfo,
})
}
/// Snapshot the terminal attributes currently established by its owner.
fn snapshot_terminal_mode(&self) -> Result<Box<dyn TerminalModeToken>, PlatformError> {
self.require(Capability::TerminalInfo)?;
Ok(Box::new(NoopTerminalModeToken))
}
/// Reapply a terminal-attribute token previously returned by this adapter.
fn apply_terminal_mode(&self, _token: &dyn TerminalModeToken) -> Result<(), PlatformError> {
self.require(Capability::TerminalInfo)
}
/// The process group that currently owns the terminal, if there is one.
fn foreground_process_group(&self) -> Result<Option<ProcessGroupId>, PlatformError> {
self.require(Capability::ForegroundTerminal)?;
Ok(None)
}
/// Give the terminal to `group` until the returned guard is dropped.
///
/// The guard, not the caller, remembers which group held the terminal
/// before: only the adapter can read that owner without racing the handover
/// it is about to perform.
fn enter_foreground(
&self,
group: ProcessGroupId,
) -> Result<Box<dyn ForegroundTerminalGuard>, PlatformError> {
self.require(Capability::ForegroundTerminal)?;
let _ = group;
Ok(Box::new(NoopForegroundTerminalGuard))
}
/// Deliver `signal` to every member of `group`.
///
/// Group-directed because the terminal stops or interrupts a whole job at
/// once; a per-process loop would race source-ordered waiting.
fn signal_process_group(
&self,
group: ProcessGroupId,
signal: JobSignal,
) -> Result<(), SignalError> {
self.require(Capability::Signals)?;
let _ = (group, signal);
Ok(())
}
/// Arrange the shell's own job-control signal dispositions.
///
/// Called only by an interactive client. A shell running a script keeps the
/// default dispositions, because a script host that ignores an interrupt
/// cannot be stopped from the keyboard.
fn install_job_control_signals(&self) -> Result<Box<dyn JobControlSignalGuard>, PlatformError> {
self.require(Capability::Signals)?;
Ok(Box::new(NoopJobControlSignalGuard))
}
/// The path of the running shell executable.
///
/// A background conditional chain re-executes the shell as its own
/// supervisor, so the shell must be able to name itself. Deliberately not
/// derived from argv zero, which a caller controls.
fn shell_executable(&self) -> Result<PathBuf, PlatformError> {
Err(PlatformError::Unsupported {
capability: Capability::ShellExecutable,
})
}
/// Ignore hang-up for this process from here on.
///
/// Installed by a chain subshell on itself. A group-directed hang-up
/// reaches the subshell and its current child together; the subshell must
/// survive it to reap that child, or the wait returns while a grandchild is
/// still alive.
fn ignore_hangup(&self) -> Result<(), PlatformError> {
Err(PlatformError::Unsupported {
capability: Capability::HangupDisposition,
})
}
/// Select native per-user home, configuration, cache, and state roots.
fn standard_directories(
&self,
environment: &dyn StandardDirectoryEnvironment,
) -> Result<StandardDirectories, PlatformError> {
self.require(Capability::StandardDirectories)?;
let _ = environment;
Err(PlatformError::Unavailable {
capability: Capability::StandardDirectories,
reason: "the adapter does not implement standard-directory discovery".to_owned(),
})
}
/// Resolve and validate one logical working directory.
fn resolve_working_directory(
&self,
request: WorkingDirectoryRequest<'_>,
) -> Result<PathBuf, WorkingDirectoryError> {
self.require(Capability::WorkingDirectory)?;
let _ = request;
Err(WorkingDirectoryError::Operation {
kind: io::ErrorKind::Unsupported,
message: "the adapter does not implement working-directory resolution".to_owned(),
})
}
/// Create one anonymous byte pipe with uniquely owned endpoints.
fn pipe(&self) -> Result<PipeEndpoints, PipeError>;
/// Open one redirection target as an opaque owned endpoint.
fn open_file(
&self,
request: FileOpenRequest<'_>,
) -> Result<Box<dyn DescriptorEndpoint>, FileActionError>;
/// Open one owned endpoint for lazy in-process file transfer.
fn open_file_io(
&self,
request: FileOpenRequest<'_>,
) -> Result<Box<dyn FileIoEndpoint>, FileActionError> {
self.require(Capability::FileActions)?;
let _ = request;
Err(FileActionError::Operation {
kind: io::ErrorKind::Unsupported,
message: "the adapter does not implement in-process file transfer".to_owned(),
})
}
/// Duplicate one deliberate inherited descriptor into an owned endpoint.
fn inherit_descriptor(
&self,
descriptor: u32,
) -> Result<Box<dyn DescriptorEndpoint>, FileActionError>;
/// Enumerate one directory as an owned, lazily advanced walk.
fn read_directory(
&self,
request: DirectoryReadRequest<'_>,
) -> Result<Box<dyn DirectoryStream>, DirectoryReadError> {
self.require(Capability::DirectoryRead)?;
let _ = request;
Err(DirectoryReadError::Operation {
kind: io::ErrorKind::Unsupported,
message: "the adapter does not implement directory enumeration".to_owned(),
})
}
/// Read one chunk from an owned pipe endpoint, returning zero at EOF.
fn read_descriptor(
&self,
endpoint: &dyn DescriptorEndpoint,
buffer: &mut [u8],
) -> Result<usize, DescriptorReadError> {
self.require(Capability::Pipes)?;
let _ = (endpoint, buffer);
Err(DescriptorReadError::InvalidEndpoint)
}
/// Execute `request.executable` directly with its explicit native argv.
fn spawn(&self, request: &SpawnRequest<'_>) -> Result<Box<dyn ChildProcess>, SpawnError>;
}
/// A deterministic in-process [`Platform`] for tests.
///
/// It performs no real host access; its capability set is scripted at
/// construction so runtime tests can drive both the supported and the
/// unsupported branches without a filesystem, process, or clock.
#[derive(Clone, Copy, Debug)]
pub struct FakePlatform {
capabilities: Capabilities,
is_terminal: bool,
is_output_terminal: bool,
terminal_size: TerminalSize,
child_stops: u32,
}
impl FakePlatform {
/// A fake platform supporting exactly `capabilities`, reporting no terminal.
pub const fn new(capabilities: Capabilities) -> Self {
Self {
capabilities,
is_terminal: false,
is_output_terminal: false,
terminal_size: TerminalSize::new(80, 24),
child_stops: 0,
}
}
/// A fake platform supporting every capability.
pub const fn full() -> Self {
Self::new(Capabilities::full())
}
/// A fake platform supporting no capability.
pub const fn none() -> Self {
Self::new(Capabilities::empty())
}
/// A fake platform with scripted terminal answers on both ends alike.
pub const fn with_terminal(
capabilities: Capabilities,
is_terminal: bool,
size: TerminalSize,
) -> Self {
Self::with_terminal_ends(capabilities, is_terminal, is_terminal, size)
}
/// A fake platform whose input and output ends answer independently.
///
/// The mixed case is the one worth representing: a session typed at from a
/// keyboard with its output redirected to a file is exactly what
/// [`Platform::is_output_terminal`] exists to detect, and a double backed
/// by a single flag cannot express it.
pub const fn with_terminal_ends(
capabilities: Capabilities,
is_terminal: bool,
is_output_terminal: bool,
size: TerminalSize,
) -> Self {
Self {
capabilities,
is_terminal,
is_output_terminal,
terminal_size: size,
child_stops: 0,
}
}
/// A fake platform whose children each report `stops` stops before completing.
///
/// Spelled out as a full struct literal rather than built from
/// [`FakePlatform::new`] with a functional-update tail: that shorthand is
/// not allowed in a `const fn`, and this constructor stays `const` so it
/// composes with the same call sites the other constructors do.
pub const fn with_stopping_children(capabilities: Capabilities, stops: u32) -> Self {
Self {
capabilities,
is_terminal: false,
is_output_terminal: false,
terminal_size: TerminalSize::new(80, 24),
child_stops: stops,
}
}
}
impl Platform for FakePlatform {
fn capabilities(&self) -> Capabilities {
self.capabilities
}
fn is_terminal(&self) -> bool {
self.is_terminal
}
fn is_output_terminal(&self) -> bool {
self.is_output_terminal
}
fn terminal_size(&self) -> Result<TerminalSize, PlatformError> {
self.require(Capability::TerminalInfo)?;
Ok(self.terminal_size)
}
fn shell_executable(&self) -> Result<PathBuf, PlatformError> {
self.require(Capability::ShellExecutable)?;
Ok(PathBuf::from("/fake/fsh"))
}
fn ignore_hangup(&self) -> Result<(), PlatformError> {
self.require(Capability::HangupDisposition)
}
fn standard_directories(
&self,
_environment: &dyn StandardDirectoryEnvironment,
) -> Result<StandardDirectories, PlatformError> {
self.require(Capability::StandardDirectories)?;
let home = PathBuf::from("/home/fake");
Ok(StandardDirectories::new(
home.clone(),
home.join(".config"),
home.join(".cache"),
home.join(".local/state"),
))
}
fn resolve_working_directory(
&self,
request: WorkingDirectoryRequest<'_>,
) -> Result<PathBuf, WorkingDirectoryError> {
self.require(Capability::WorkingDirectory)?;
let path = if request.path().is_absolute() {
request.path().to_owned()
} else {
request.cwd().join(request.path())
};
Ok(normalize_path(&path))
}
fn pipe(&self) -> Result<PipeEndpoints, PipeError> {
self.require(Capability::Pipes)?;
Ok(PipeEndpoints::new(
Box::new(FakeDescriptorEndpoint),
Box::new(FakeDescriptorEndpoint),
))
}
fn open_file(
&self,
_request: FileOpenRequest<'_>,
) -> Result<Box<dyn DescriptorEndpoint>, FileActionError> {
self.require(Capability::FileActions)?;
Ok(Box::new(FakeDescriptorEndpoint))
}
fn open_file_io(
&self,
_request: FileOpenRequest<'_>,
) -> Result<Box<dyn FileIoEndpoint>, FileActionError> {
self.require(Capability::FileActions)?;
Ok(Box::new(FakeFileIoEndpoint))
}
fn inherit_descriptor(
&self,
_descriptor: u32,
) -> Result<Box<dyn DescriptorEndpoint>, FileActionError> {
self.require(Capability::FileActions)?;
Ok(Box::new(FakeDescriptorEndpoint))
}
fn read_directory(
&self,
_request: DirectoryReadRequest<'_>,
) -> Result<Box<dyn DirectoryStream>, DirectoryReadError> {
self.require(Capability::DirectoryRead)?;
Ok(Box::new(EmptyDirectoryStream))
}
fn read_descriptor(
&self,
_endpoint: &dyn DescriptorEndpoint,
_buffer: &mut [u8],
) -> Result<usize, DescriptorReadError> {
self.require(Capability::Pipes)?;
Ok(0)
}
fn spawn(&self, request: &SpawnRequest<'_>) -> Result<Box<dyn ChildProcess>, SpawnError> {
self.require(Capability::ProcessSpawn)?;
if request.process_group().requires_capability() {
self.require(Capability::ProcessGroups)?;
}
Ok(Box::new(FakeChild::with_stops(
request.process_group(),
self.child_stops,
)))
}
}
/// A shared tally of the terminal calls a [`RecordingPlatform`] received.
///
/// [`FakePlatform`] is `Copy` and carries no interior mutability, so a caller
/// that takes its platform by value leaves the test no way to observe what was
/// asked of it. A log is cloned off the platform before it is handed over and
/// read back afterwards, which makes "raw mode was requested" an assertion
/// rather than an inference from the outcome.
#[derive(Clone, Debug, Default)]
pub struct TerminalCallLog {
raw_mode_entries: Arc<AtomicUsize>,
terminal_size_queries: Arc<AtomicUsize>,
foreground_handovers: Arc<Mutex<Vec<ProcessGroupId>>>,
foreground_releases: Arc<AtomicUsize>,
terminal_mode_snapshots: Arc<AtomicUsize>,
terminal_mode_applications: Arc<AtomicUsize>,
}
impl TerminalCallLog {
/// How often raw mode was requested, whether or not the request succeeded.
#[must_use]
pub fn raw_mode_entries(&self) -> usize {
self.raw_mode_entries.load(Ordering::Relaxed)
}
/// How often the terminal size was queried.
#[must_use]
pub fn terminal_size_queries(&self) -> usize {
self.terminal_size_queries.load(Ordering::Relaxed)
}
/// Every group the terminal was handed to, in call order.
#[must_use]
pub fn foreground_handovers(&self) -> Vec<ProcessGroupId> {
self.foreground_handovers
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
/// How often the terminal came back, whether restored explicitly or dropped.
///
/// Counted at the guard rather than at the platform: restoration on drop is
/// the path that a runtime error or a panic takes, and a tally that missed
/// it could not tell a returned terminal from a leaked one.
#[must_use]
pub fn foreground_releases(&self) -> usize {
self.foreground_releases.load(Ordering::Relaxed)
}
/// How often a foreground job's terminal attributes were captured.
#[must_use]
pub fn terminal_mode_snapshots(&self) -> usize {
self.terminal_mode_snapshots.load(Ordering::Relaxed)
}
/// How often retained job terminal attributes were reapplied.
#[must_use]
pub fn terminal_mode_applications(&self) -> usize {
self.terminal_mode_applications.load(Ordering::Relaxed)
}
}
/// A [`ForegroundTerminalGuard`] that tallies its release exactly once.
#[derive(Debug)]
struct RecordingForegroundGuard {
inner: Box<dyn ForegroundTerminalGuard>,
releases: Arc<AtomicUsize>,
released: bool,
}
impl ForegroundTerminalGuard for RecordingForegroundGuard {
fn restore(&mut self) -> Result<(), PlatformError> {
let result = self.inner.restore();
if !self.released {
self.released = true;
self.releases.fetch_add(1, Ordering::Relaxed);
}
result
}
fn previous_owner(&self) -> Option<ProcessGroupId> {
self.inner.previous_owner()
}
}
impl Drop for RecordingForegroundGuard {
fn drop(&mut self) {
if !self.released {
self.released = true;
self.releases.fetch_add(1, Ordering::Relaxed);
}
}
}
/// One spawn a [`RecordingPlatform`] observed, in call order.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SpawnRecord {
executable: PathBuf,
argv: Vec<OsString>,
requested: ProcessGroup,
process_group: Option<ProcessGroupId>,
}
impl SpawnRecord {
/// The executable path passed to the platform.
#[must_use]
pub fn executable(&self) -> &Path {
&self.executable
}
/// The complete native argv passed to the platform.
#[must_use]
pub fn argv(&self) -> &[OsString] {
&self.argv
}
/// The placement the caller asked for.
#[must_use]
pub const fn requested(&self) -> ProcessGroup {
self.requested
}
/// The group the resulting child reported, when it has one.
#[must_use]
pub const fn process_group(&self) -> Option<ProcessGroupId> {
self.process_group
}
}
/// The spawns a [`RecordingPlatform`] received, in order.
///
/// Job control is decided before a child exists and is invisible in its exit
/// status, so a test that only sees statuses cannot tell one process group per
/// pipeline from none at all. The log makes the placement itself observable.
#[derive(Clone, Debug, Default)]
pub struct SpawnCallLog {
records: Arc<Mutex<Vec<SpawnRecord>>>,
}
impl SpawnCallLog {
/// Every spawn recorded so far, in call order.
#[must_use]
pub fn records(&self) -> Vec<SpawnRecord> {
self.records
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
fn push(&self, record: SpawnRecord) {
self.records
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(record);
}
}
/// One group-directed signal a [`RecordingPlatform`] observed, in call order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SignalRecord {
group: ProcessGroupId,
signal: JobSignal,
}
impl SignalRecord {
/// The group the signal was addressed to.
#[must_use]
pub const fn group(&self) -> ProcessGroupId {
self.group
}
/// The signal the caller chose.
#[must_use]
pub const fn signal(&self) -> JobSignal {
self.signal
}
}
/// The group-directed signals a [`RecordingPlatform`] received, in order.
///
/// A resume that works and a resume that was never attempted produce the same
/// completed job, so the log is the only way to assert that the executor
/// actually sent one.
#[derive(Clone, Debug, Default)]
pub struct SignalCallLog {
records: Arc<Mutex<Vec<SignalRecord>>>,
}
impl SignalCallLog {
/// Every signal recorded so far, in call order.
#[must_use]
pub fn records(&self) -> Vec<SignalRecord> {
self.records
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
fn push(&self, record: SignalRecord) {
self.records
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(record);
}
}
/// A [`FakePlatform`] that records the terminal and spawn calls it receives.
///
/// Every method delegates, so the wrapped platform's scripted capability set
/// still decides whether a call succeeds; recording adds only the fact that the
/// call was made.
#[derive(Clone, Debug)]
pub struct RecordingPlatform {
inner: FakePlatform,
log: TerminalCallLog,
spawns: SpawnCallLog,
signals: SignalCallLog,
}
impl RecordingPlatform {
/// Record the terminal calls made against `inner`.
#[must_use]
pub fn new(inner: FakePlatform) -> Self {
Self {
inner,
log: TerminalCallLog::default(),
spawns: SpawnCallLog::default(),
signals: SignalCallLog::default(),
}
}
/// A handle to this platform's log, still readable after the platform moves.
#[must_use]
pub fn log(&self) -> TerminalCallLog {
self.log.clone()
}
/// A handle to this platform's spawn log, readable after the platform moves.
#[must_use]
pub fn spawn_log(&self) -> SpawnCallLog {
self.spawns.clone()
}
/// A handle to this platform's signal log, readable after the platform moves.
#[must_use]
pub fn signal_log(&self) -> SignalCallLog {
self.signals.clone()
}
}
impl Platform for RecordingPlatform {
fn capabilities(&self) -> Capabilities {
self.inner.capabilities()
}
fn is_terminal(&self) -> bool {
self.inner.is_terminal()
}
fn is_output_terminal(&self) -> bool {
self.inner.is_output_terminal()
}
fn terminal_size(&self) -> Result<TerminalSize, PlatformError> {
self.log
.terminal_size_queries
.fetch_add(1, Ordering::Relaxed);
self.inner.terminal_size()
}
fn enter_raw_mode(&self) -> Result<Box<dyn TerminalModeGuard>, PlatformError> {
self.log.raw_mode_entries.fetch_add(1, Ordering::Relaxed);
self.inner.enter_raw_mode()
}
fn read_terminal_input(
&self,
buffer: &mut [u8],
timeout: Duration,
) -> Result<Option<usize>, PlatformError> {
self.inner.read_terminal_input(buffer, timeout)
}
fn snapshot_terminal_mode(&self) -> Result<Box<dyn TerminalModeToken>, PlatformError> {
let token = self.inner.snapshot_terminal_mode()?;
self.log
.terminal_mode_snapshots
.fetch_add(1, Ordering::Relaxed);
Ok(token)
}
fn apply_terminal_mode(&self, token: &dyn TerminalModeToken) -> Result<(), PlatformError> {
self.inner.apply_terminal_mode(token)?;
self.log
.terminal_mode_applications
.fetch_add(1, Ordering::Relaxed);
Ok(())
}
fn foreground_process_group(&self) -> Result<Option<ProcessGroupId>, PlatformError> {
self.inner.foreground_process_group()
}
fn enter_foreground(
&self,
group: ProcessGroupId,
) -> Result<Box<dyn ForegroundTerminalGuard>, PlatformError> {
let guard = self.inner.enter_foreground(group)?;
self.log
.foreground_handovers
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(group);
Ok(Box::new(RecordingForegroundGuard {
inner: guard,
releases: Arc::clone(&self.log.foreground_releases),
released: false,
}))
}
fn shell_executable(&self) -> Result<PathBuf, PlatformError> {
self.inner.shell_executable()
}
fn ignore_hangup(&self) -> Result<(), PlatformError> {
self.inner.ignore_hangup()
}
fn standard_directories(
&self,
environment: &dyn StandardDirectoryEnvironment,
) -> Result<StandardDirectories, PlatformError> {
self.inner.standard_directories(environment)
}
fn resolve_working_directory(
&self,
request: WorkingDirectoryRequest<'_>,
) -> Result<PathBuf, WorkingDirectoryError> {
self.inner.resolve_working_directory(request)
}
fn pipe(&self) -> Result<PipeEndpoints, PipeError> {
self.inner.pipe()
}
fn open_file(
&self,
request: FileOpenRequest<'_>,
) -> Result<Box<dyn DescriptorEndpoint>, FileActionError> {
self.inner.open_file(request)
}
fn open_file_io(
&self,
request: FileOpenRequest<'_>,
) -> Result<Box<dyn FileIoEndpoint>, FileActionError> {
self.inner.open_file_io(request)
}
fn inherit_descriptor(
&self,
descriptor: u32,
) -> Result<Box<dyn DescriptorEndpoint>, FileActionError> {
self.inner.inherit_descriptor(descriptor)
}
fn read_directory(
&self,
request: DirectoryReadRequest<'_>,
) -> Result<Box<dyn DirectoryStream>, DirectoryReadError> {
self.inner.read_directory(request)
}
fn read_descriptor(
&self,
endpoint: &dyn DescriptorEndpoint,
buffer: &mut [u8],
) -> Result<usize, DescriptorReadError> {
self.inner.read_descriptor(endpoint, buffer)
}
fn spawn(&self, request: &SpawnRequest<'_>) -> Result<Box<dyn ChildProcess>, SpawnError> {
let child = self.inner.spawn(request)?;
self.spawns.push(SpawnRecord {
executable: request.executable().to_owned(),
argv: request.argv().to_vec(),
requested: request.process_group(),
process_group: child.process_group(),
});
Ok(child)
}
fn signal_process_group(
&self,
group: ProcessGroupId,
signal: JobSignal,
) -> Result<(), SignalError> {
self.signals.push(SignalRecord { group, signal });
self.inner.signal_process_group(group, signal)
}
fn install_job_control_signals(&self) -> Result<Box<dyn JobControlSignalGuard>, PlatformError> {
self.inner.install_job_control_signals()
}
}
fn normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
normalized.push(component.as_os_str());
}
}
}
normalized
}
/// Opaque endpoint used by [`FakePlatform`] without touching a host resource.
#[derive(Clone, Copy, Debug, Default)]
pub struct FakeDescriptorEndpoint;
impl DescriptorEndpoint for FakeDescriptorEndpoint {
fn as_any(&self) -> &dyn Any {
self
}
fn write(&mut self, buffer: &[u8]) -> Result<usize, DescriptorWriteError> {
Ok(buffer.len())
}
}
/// Empty in-process file endpoint used by [`FakePlatform`].
#[derive(Clone, Copy, Debug, Default)]
pub struct FakeFileIoEndpoint;
impl FileIoEndpoint for FakeFileIoEndpoint {
fn read(&mut self, _buffer: &mut [u8]) -> Result<usize, FileActionError> {
Ok(0)
}
fn write(&mut self, buffer: &[u8]) -> Result<usize, FileActionError> {
Ok(buffer.len())
}
}
/// Deterministic host-free directory walk returned by [`FakePlatform`].
///
/// It reports an empty directory rather than scripted entries: the fake never
/// touches a host, and a runtime test that needs entries drives the value
/// mapping directly instead of going through a platform.
#[derive(Clone, Copy, Debug, Default)]
pub struct EmptyDirectoryStream;
impl DirectoryStream for EmptyDirectoryStream {
fn next_entry(&mut self) -> Result<Option<DirectoryEntry>, DirectoryReadError> {
Ok(None)
}
}
/// The signal number [`FakeChild`] reports for a scripted stop.
///
/// Exported so a test asserts against this name rather than repeating a bare
/// number that reads like a host signal and is not one.
pub const FAKE_STOP_SIGNAL: i32 = 18;
/// Deterministic host-free child returned by [`FakePlatform`].
///
/// Identities are unique and nonzero because job control is built on them: a
/// fake whose children all answered the same identifier could not distinguish
/// "every pipeline member joined one group" from "no member moved at all".
#[derive(Clone, Copy, Debug)]
pub struct FakeChild {
id: u64,
process_group: Option<ProcessGroupId>,
stops_remaining: u32,
}
impl FakeChild {
/// A child with a fresh identity and the group `placement` asks for.
#[must_use]
pub fn new(placement: ProcessGroup) -> Self {
static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed) as u64;
let process_group = match placement {
ProcessGroup::Inherit => None,
ProcessGroup::New => ProcessGroupId::new(id),
ProcessGroup::Join(group) => Some(group),
};
Self {
id,
process_group,
stops_remaining: 0,
}
}
/// A child that reports `stops` stops before it completes.
///
/// The reported number is a fixed stand-in, not a host stop signal. This
/// crate has no access to the host's signal numbering, and that numbering
/// differs between the supported hosts, so a fake that claimed to reproduce
/// it would be wrong on one of them. Only the adapter that observes a real
/// stop can report a real number.
#[must_use]
pub fn with_stops(placement: ProcessGroup, stops: u32) -> Self {
Self {
stops_remaining: stops,
..Self::new(placement)
}
}
}
impl Default for FakeChild {
fn default() -> Self {
Self::new(ProcessGroup::Inherit)
}
}
impl ChildProcess for FakeChild {
fn id(&self) -> u64 {
self.id
}
fn process_group(&self) -> Option<ProcessGroupId> {
self.process_group
}
fn wait(&mut self) -> Result<ProcessStatus, WaitError> {
Ok(ProcessStatus::Exited(0))
}
fn wait_for_transition(&mut self) -> Result<ProcessTransition, WaitError> {
if self.stops_remaining > 0 {
self.stops_remaining -= 1;
return Ok(ProcessTransition::Stopped {
signal: FAKE_STOP_SIGNAL,
});
}
self.wait().map(ProcessTransition::Completed)
}
fn terminate(&mut self) -> Result<(), TerminateError> {
Ok(())
}
}
/// A handover that moved no terminal; restoring it always succeeds.
#[derive(Debug)]
pub struct NoopForegroundTerminalGuard;
impl ForegroundTerminalGuard for NoopForegroundTerminalGuard {
fn restore(&mut self) -> Result<(), PlatformError> {
Ok(())
}
fn previous_owner(&self) -> Option<ProcessGroupId> {
None
}
}
/// A guard that owns no terminal state; restoring it always succeeds.
#[derive(Debug)]
pub struct NoopTerminalModeGuard;
impl TerminalModeGuard for NoopTerminalModeGuard {
fn restore(&mut self) -> Result<(), PlatformError> {
Ok(())
}
}
/// Opaque terminal-mode token used by capability-only adapters and tests.
#[derive(Clone, Copy, Debug, Default)]
pub struct NoopTerminalModeToken;
impl TerminalModeToken for NoopTerminalModeToken {
fn as_any(&self) -> &dyn Any {
self
}
}