diff --git a/litebox/src/broker/mod.rs b/litebox/src/broker/mod.rs index 15afab3466..1ce56d312a 100644 --- a/litebox/src/broker/mod.rs +++ b/litebox/src/broker/mod.rs @@ -23,6 +23,7 @@ use litebox_broker_protocol::process::{ ChildExit, ChildSelector, MAX_CHILD_MEMORY_WRITE_SIZE, MAX_CHILD_OBJECT_DUPLICATES, MAX_PROCESS_BOOTSTRAP_SIZE, ProcessExitStatus, ProcessIdentity, ProcessInfo, ThreadInfo, }; +use litebox_broker_protocol::process_group::{ProcessGroupError, ProcessGroupMembership}; use litebox_broker_protocol::random::MAX_RANDOM_TRANSFER_SIZE; use litebox_broker_protocol::readiness::ReadinessFlags; use litebox_broker_protocol::readiness_queue::{ @@ -161,6 +162,20 @@ pub(crate) trait BrokerControl: Send + Sync { process_id: litebox_broker_protocol::ProcessId, ) -> core::result::Result<(), BrokerControlError>; + fn get_foreground_group( + &self, + terminal: ObjectHandle, + ) -> core::result::Result< + core::result::Result, + BrokerControlError, + >; + + fn set_foreground_group( + &self, + terminal: ObjectHandle, + process_group: litebox_broker_protocol::ProcessGroupId, + ) -> core::result::Result, BrokerControlError>; + fn create_thread(&self) -> core::result::Result; fn exit_thread(&self, thread_id: ThreadId) -> core::result::Result<(), BrokerControlError>; @@ -930,6 +945,24 @@ where self.request(|local| local.create_session(process_id)) } + fn get_foreground_group( + &self, + terminal: ObjectHandle, + ) -> core::result::Result< + core::result::Result, + BrokerControlError, + > { + self.request(|local| local.get_foreground_group(terminal)) + } + + fn set_foreground_group( + &self, + terminal: ObjectHandle, + process_group: litebox_broker_protocol::ProcessGroupId, + ) -> core::result::Result, BrokerControlError> { + self.request(|local| local.set_foreground_group(terminal, process_group)) + } + fn create_thread(&self) -> core::result::Result { self.request(BrokerLocal::create_thread) } diff --git a/litebox/src/process.rs b/litebox/src/process.rs index 940c68f44c..ec71202831 100644 --- a/litebox/src/process.rs +++ b/litebox/src/process.rs @@ -13,6 +13,7 @@ use litebox_broker_protocol::process::{ ChildExit, ChildSelector, MAX_CHILD_MEMORY_WRITE_SIZE, ProcessExitStatus, ProcessIdentity, ProcessInfo, ThreadInfo, }; +use litebox_broker_protocol::process_group::{ProcessGroupError, ProcessGroupMembership}; use litebox_broker_protocol::signal::{SignalEvent, SignalTarget}; use litebox_broker_protocol::{ObjectHandle, ProcessGroupId, ProcessId, ThreadId}; use litebox_platform::time::TimeProvider; @@ -58,6 +59,15 @@ pub enum ProcessError { /// No process has the target process ID. #[error("no such process")] NoSuchProcess, + /// The file is not this process's controlling terminal. + #[error("not the controlling terminal")] + NotControllingTerminal, + /// No process group has the target process group ID. + #[error("no such process group")] + NoSuchProcessGroup, + /// The target process group is in another session than this process. + #[error("process group is in another session")] + NotInSession, } impl LiteBox { @@ -175,6 +185,37 @@ impl LiteBox { broker.create_session(process_id).map_err(no_such_process) } + /// Returns the foreground process group of this process's session and the + /// session, given its controlling terminal `terminal`. + /// + /// Returns [`ProcessError::NotControllingTerminal`] if `terminal` is not + /// this process's controlling terminal. + pub fn get_foreground_group( + &self, + terminal: &File, + ) -> Result { + let broker = self.broker_control().ok_or(ProcessError::Unavailable)?; + Ok(broker.get_foreground_group(terminal.handle())??) + } + + /// Moves `process_group` into the foreground of this process's controlling + /// terminal `terminal`, so the terminal's events go to its members. + /// + /// Returns, in order of precedence, + /// [`ProcessError::NotControllingTerminal`] if `terminal` is not this + /// process's controlling terminal, [`ProcessError::NoSuchProcessGroup`] if + /// no process group has the ID `process_group`, and + /// [`ProcessError::NotInSession`] if the process group is in another + /// session. + pub fn set_foreground_group( + &self, + terminal: &File, + process_group: ProcessGroupId, + ) -> Result<(), ProcessError> { + let broker = self.broker_control().ok_or(ProcessError::Unavailable)?; + Ok(broker.set_foreground_group(terminal.handle(), process_group)??) + } + /// Opens the signals other processes send to this process and the exits /// of its children, including those before it is opened. /// @@ -405,6 +446,17 @@ fn no_such_process(error: BrokerControlError) -> ProcessError { } } +impl From for ProcessError { + fn from(error: ProcessGroupError) -> Self { + match error { + ProcessGroupError::NotControllingTerminal => Self::NotControllingTerminal, + ProcessGroupError::NoSuchProcessGroup => Self::NoSuchProcessGroup, + ProcessGroupError::NotInSession => Self::NotInSession, + _ => Self::ServiceFailed, + } + } +} + impl From for ProcessError { fn from(error: BrokerControlError) -> Self { match error { diff --git a/litebox_broker_core/src/fs/mod.rs b/litebox_broker_core/src/fs/mod.rs index aabb3d6278..730d91bdf8 100644 --- a/litebox_broker_core/src/fs/mod.rs +++ b/litebox_broker_core/src/fs/mod.rs @@ -31,7 +31,9 @@ pub use litebox_broker_protocol::fs::{ FileDirectoryEntry as DirEntry, FileMode as Mode, FileNodeInfo as NodeInfo, FileSeekWhence as SeekWhence, FileStatus, FileType, FileUser as UserInfo, }; -pub(crate) use service::{File, get_status_flags, open_without_reference, set_status_flags}; +pub(crate) use service::{ + File, get_status_flags, handle_terminal, open_without_reference, set_status_flags, +}; pub use service::{ FileResult, FileService, UnsupportedFileService, chmod, chown, handle_path, handle_status, is_terminal, mkdir, open, path_status, read, read_directory, read_link, resolve_path, rmdir, diff --git a/litebox_broker_core/src/fs/service.rs b/litebox_broker_core/src/fs/service.rs index 0dbc28ee98..0b2b6c78b2 100644 --- a/litebox_broker_core/src/fs/service.rs +++ b/litebox_broker_core/src/fs/service.rs @@ -647,6 +647,15 @@ pub fn is_terminal(process: &BrokerProcess, handle: ObjectHandle) -> Result Result>> { + let file = file_with_any_rights(process, handle, ObjectRights::WAIT | ObjectRights::WRITE)?; + Ok(file.terminal) +} + /// Controls a shared terminal through any open file handle that refers to it. /// /// Like Linux, every operation only requires an open handle, not a particular access mode; diff --git a/litebox_broker_core/src/lib.rs b/litebox_broker_core/src/lib.rs index d836f89ccf..74cbbd2c72 100644 --- a/litebox_broker_core/src/lib.rs +++ b/litebox_broker_core/src/lib.rs @@ -346,8 +346,8 @@ pub struct BrokerCore { pub(crate) socket_provider: Arc, pub(crate) timer_provider: Arc, pub(crate) fs: Arc, - /// Guest terminal whose events reach the first root process's group. - pub(crate) terminal: Option>, + /// Guest terminal, controlling the first root process's session. + pub(crate) terminal: Option>, pub(crate) socket_ports: BrokerSocketPorts, pub(crate) process_lifecycle_sink: Arc, } @@ -421,10 +421,11 @@ impl BrokerCore { /// Makes `terminal`, which the file service serves as `/dev/tty`, the /// controlling terminal of the first root process's session: the terminal - /// then signals that process's group. Set it before sharing the core. + /// then signals that session's foreground process group, initially that + /// process's group. Set it before sharing the core. #[must_use] pub fn with_terminal(mut self, terminal: Arc) -> Self { - self.terminal = Some(terminal); + self.terminal = Some(Arc::new(process_group::ControllingTerminal::new(terminal))); self } @@ -548,8 +549,8 @@ impl BrokerCore { /// Creates one process and its initial thread. /// /// If initial-thread creation fails, the process is discarded before the - /// error is returned. A root process's group receives the events of the - /// [terminal](Self::with_terminal) unless an earlier root's does. + /// error is returned. The first root process's session gets the + /// [terminal](Self::with_terminal). /// /// # Panics /// @@ -564,7 +565,7 @@ impl BrokerCore { match process.create_initial_thread() { Ok(()) => { if let (None, Some(terminal)) = (parent_id, &self.terminal) { - terminal.attach(process_group::terminal_sink(&process)); + terminal.attach(&process); } Ok(process) } diff --git a/litebox_broker_core/src/process_group.rs b/litebox_broker_core/src/process_group.rs index 62cab67e9d..979e18ca29 100644 --- a/litebox_broker_core/src/process_group.rs +++ b/litebox_broker_core/src/process_group.rs @@ -9,18 +9,26 @@ //! leads its own. A group or session exists while any process, including an //! exited one whose exit is not yet collected, belongs to it, and its ID is //! not reused until then, nor while a terminal can still signal the group. +//! +//! The broker's terminal controls the first root process's session and +//! signals its foreground group, which starts as that process's group and +//! which processes in the session can change. use alloc::sync::{Arc, Weak}; -use litebox_broker_protocol::process_group::ProcessGroupMembership; +use litebox_broker_protocol::process_group::{ProcessGroupError, ProcessGroupMembership}; use litebox_broker_protocol::terminal::TerminalEvents; -use litebox_broker_protocol::{ProcessGroupId, ProcessId}; -use spin::Mutex; +use litebox_broker_protocol::{ObjectHandle, ProcessGroupId, ProcessId, SessionId}; +use spin::{Mutex, Once}; use crate::id::IdAllocator; -use crate::terminal::TerminalEventSink; +use crate::terminal::{TerminalEndpoint, TerminalEventSink}; use crate::{BrokerError, BrokerProcess, ProcessRegistry, Result}; +/// Result of a process group operation that can fail in a way the guest ABI +/// reports. +pub type ProcessGroupResult = core::result::Result; + /// Moves the process `target` into `process_group`, creating the group if it /// is `target`'s ID. /// @@ -92,27 +100,113 @@ pub fn create_session(process: &BrokerProcess, target: ProcessId) -> Result<()> } } -/// Delivers a terminal's events to the process group that attached it. +/// The broker's guest terminal, which controls the session of the first root +/// process. +pub(crate) struct ControllingTerminal { + endpoint: Arc, + /// Set when the first root process attaches the terminal. + sink: Once>, +} + +impl ControllingTerminal { + pub(crate) fn new(endpoint: Arc) -> Self { + Self { + endpoint, + sink: Once::new(), + } + } + + /// Makes the terminal control `process`'s session, with `process`'s group + /// in the foreground, unless it already controls a session. + pub(crate) fn attach(&self, process: &BrokerProcess) { + self.sink.call_once(|| { + let sink = { + // The group's IDs cannot be released before they are retained. + let _tree = process.core.process_tree_lock.lock(); + let membership = process.membership(); + { + let mut ids = process.core.ids.lock(); + ids.retain(membership.process_group.0); + ids.retain(membership.session.0); + } + Arc::new(TerminalSink { + processes: Arc::downgrade(&process.core.processes), + tree_lock: Arc::clone(&process.core.process_tree_lock), + ids: Arc::clone(&process.core.ids), + session: membership.session, + foreground: Mutex::new(membership.process_group), + }) + }; + // Endpoints may post events while attaching, which takes the + // process tree lock. + self.endpoint.attach(sink.clone()); + sink + }); + } +} + +/// Delivers a terminal's events to its foreground process group. /// -/// The sink keeps the group's and session's IDs reserved, so they cannot name -/// another group or session while the terminal can still signal this one. +/// The sink keeps the IDs of that group and of the terminal's session +/// reserved, so they cannot name another group or session while the terminal +/// can still signal this one. struct TerminalSink { processes: Weak, tree_lock: Arc>, ids: Arc>, - membership: ProcessGroupMembership, + session: SessionId, + /// Changes only under the process tree lock. + foreground: Mutex, +} + +impl TerminalSink { + /// Returns the foreground process group, in the terminal's session. + fn get_foreground_group(&self) -> ProcessGroupMembership { + ProcessGroupMembership { + process_group: *self.foreground.lock(), + session: self.session, + } + } + + /// Moves `process_group` to the foreground under the process tree lock, + /// which `process` holds. + fn set_foreground_group( + &self, + process: &BrokerProcess, + process_group: ProcessGroupId, + ) -> ProcessGroupResult<()> { + let session = process + .core + .registered_processes() + .iter() + .map(|member| member.membership()) + .find(|membership| membership.process_group == process_group) + .ok_or(ProcessGroupError::NoSuchProcessGroup)? + .session; + if session != self.session { + return Err(ProcessGroupError::NotInSession); + } + let mut foreground = self.foreground.lock(); + let mut ids = self.ids.lock(); + ids.retain(process_group.0); + ids.release(foreground.0); + *foreground = process_group; + Ok(()) + } } impl TerminalEventSink for TerminalSink { fn post(&self, events: TerminalEvents) { let targets = { - // Members cannot move between groups while they are selected. + // The foreground group and its members cannot change while they + // are selected. let _tree = self.tree_lock.lock(); let Some(processes) = self.processes.upgrade() else { return; }; + let foreground = self.get_foreground_group(); let mut targets = crate::registered_processes(&processes); - targets.retain(|target| target.membership() == self.membership); + targets.retain(|target| target.membership() == foreground); targets }; for target in targets { @@ -124,44 +218,82 @@ impl TerminalEventSink for TerminalSink { impl Drop for TerminalSink { fn drop(&mut self) { let mut ids = self.ids.lock(); - ids.release(self.membership.process_group.0); - ids.release(self.membership.session.0); + ids.release(self.foreground.get_mut().0); + ids.release(self.session.0); } } -/// Returns a sink that delivers terminal events to `process`'s current process -/// group. -pub(crate) fn terminal_sink(process: &BrokerProcess) -> Arc { - // The group's IDs cannot be released before they are retained. +/// Returns the sink of `terminal` if it is the caller's controlling terminal. +fn controlling_terminal<'a>( + process: &'a BrokerProcess, + terminal: Option<&Arc>, +) -> ProcessGroupResult<&'a TerminalSink> { + let terminal = terminal.ok_or(ProcessGroupError::NotControllingTerminal)?; + process + .core + .terminal + .as_ref() + .filter(|controlling| { + core::ptr::addr_eq(Arc::as_ptr(&controlling.endpoint), Arc::as_ptr(terminal)) + }) + .and_then(|controlling| controlling.sink.get()) + .filter(|sink| process.membership().session == sink.session) + .map(|sink| &**sink) + .ok_or(ProcessGroupError::NotControllingTerminal) +} + +/// Returns the foreground process group of the caller's controlling terminal, +/// given an open handle `terminal` to it. +pub fn get_foreground_group( + process: &BrokerProcess, + terminal: ObjectHandle, +) -> Result> { + let terminal = crate::fs::handle_terminal(process, terminal)?; let _tree = process.core.process_tree_lock.lock(); - let membership = process.membership(); - { - let mut ids = process.core.ids.lock(); - ids.retain(membership.process_group.0); - ids.retain(membership.session.0); - } - Arc::new(TerminalSink { - processes: Arc::downgrade(&process.core.processes), - tree_lock: Arc::clone(&process.core.process_tree_lock), - ids: Arc::clone(&process.core.ids), - membership, - }) + Ok(controlling_terminal(process, terminal.as_ref()).map(TerminalSink::get_foreground_group)) +} + +/// Makes `process_group` the foreground group of the caller's controlling +/// terminal, given an open handle `terminal` to it. +/// +/// Fails with [`ProcessGroupError::NotControllingTerminal`] if `terminal` does +/// not refer to the caller's controlling terminal, then with +/// [`ProcessGroupError::NoSuchProcessGroup`] if no process is in +/// `process_group`, and then with [`ProcessGroupError::NotInSession`] if +/// `process_group` is in another session. +pub fn set_foreground_group( + process: &BrokerProcess, + terminal: ObjectHandle, + process_group: ProcessGroupId, +) -> Result> { + let terminal = crate::fs::handle_terminal(process, terminal)?; + let _tree = process.core.process_tree_lock.lock(); + Ok(controlling_terminal(process, terminal.as_ref()) + .and_then(|sink| sink.set_foreground_group(process, process_group))) } #[cfg(test)] mod tests { use alloc::sync::Arc; - use litebox_broker_protocol::process::ProcessExitStatus; - use litebox_broker_protocol::process_group::ProcessGroupMembership; + use litebox_broker_protocol::fs::{FileAccessMode, FileMode, FileOpenFlags, FileUser}; + use litebox_broker_protocol::process::{ChildSelector, ProcessExitStatus}; + use litebox_broker_protocol::process_group::{ProcessGroupError, ProcessGroupMembership}; use litebox_broker_protocol::readiness::ReadinessFlags; use litebox_broker_protocol::signal::SignalEvent; use litebox_broker_protocol::terminal::TerminalEvents; - use litebox_broker_protocol::{ProcessGroupId, ProcessId}; + use litebox_broker_protocol::{ObjectHandle, ProcessGroupId, ProcessId}; + use crate::fs::composer::Composer; + use crate::fs::devices::Devices; + use crate::fs::resolver::Resolver; use crate::id::IdAllocator; + use crate::random::TestRandomProvider; + use crate::readiness::ReadinessSink; use crate::readiness::tests::TestReadinessSink; - use crate::test_support::TestBrokerCoreBuilder; + use crate::stdio::StdioProvider as _; + use crate::test_platform::TestPlatform; + use crate::test_support::{TestBrokerCoreBuilder, TestStdioProvider}; use crate::{ BrokerCore, BrokerError, BrokerProcess, CallerCredential, ObjectRights, PolicyEngine, Result, @@ -325,9 +457,6 @@ mod tests { #[test] fn terminal_events_reach_only_the_first_root_group_and_coalesce() { - use crate::stdio::StdioProvider as _; - use crate::test_support::TestStdioProvider; - let stdio = TestStdioProvider::default().with_controlling_terminal(); let broker = broker().with_terminal(stdio.terminal().unwrap()); let root = process(&broker, None); @@ -361,4 +490,160 @@ mod tests { ); } } + + /// Returns a broker whose terminal is `/dev/tty`, while `/other/tty` is + /// another terminal, and the broker terminal's provider. + fn terminal_broker() -> (BrokerCore, Arc) { + let stdio = Arc::new(TestStdioProvider::default().with_controlling_terminal()); + let devices = |stdio: Arc| { + move |allocator| Devices::new(allocator, stdio, Arc::new(TestRandomProvider)) + }; + let other = Arc::new(TestStdioProvider::default().with_controlling_terminal()); + let fs = Composer::builder() + .mount("/dev", devices(Arc::clone(&stdio))) + .mount("/other", devices(other)) + .build() + .unwrap(); + let broker = TestBrokerCoreBuilder::new( + PolicyEngine::with_unauthenticated_rights(ObjectRights::all()) + .with_process_duplication_enabled(true), + ) + .with_file_service(Arc::new(Resolver::::new(fs))) + .build() + .unwrap() + .with_terminal(stdio.terminal().unwrap()); + (broker, stdio) + } + + fn open(process: &BrokerProcess, path: &str, flags: FileOpenFlags) -> ObjectHandle { + let sink: Arc = Arc::new(TestReadinessSink::default()); + crate::fs::open( + process, + path, + FileUser::ROOT, + FileAccessMode::ReadWrite, + flags, + FileMode::empty(), + &sink, + ) + .unwrap() + .unwrap() + } + + #[test] + fn the_session_moves_terminal_events_to_its_foreground_group() { + let (broker, stdio) = terminal_broker(); + let root = process(&broker, None); + let job = process(&broker, Some(&root)); + super::set(&root, job.id(), job.id().into()).unwrap(); + let detached = process(&broker, Some(&root)); + super::create_session(&detached, detached.id()).unwrap(); + let other_root = process(&broker, None); + let [root_tty, job_tty, detached_tty, other_root_tty] = + [&root, &job, &detached, &other_root] + .map(|process| open(process, "/dev/tty", FileOpenFlags::NONE)); + + // The first root's group starts in the foreground. + assert_eq!( + super::get_foreground_group(&job, job_tty), + Ok(Ok(membership(&root, &root))) + ); + // Only the broker's terminal is a controlling terminal, and only of + // the first root's session. + for (process, terminal) in [ + (&detached, detached_tty), + (&other_root, other_root_tty), + (&root, open(&root, "/other/tty", FileOpenFlags::NONE)), + (&root, open(&root, "/dev/stdout", FileOpenFlags::NONE)), + (&root, open(&root, "/dev/tty", FileOpenFlags::PATH)), + ] { + assert_eq!( + super::get_foreground_group(process, terminal), + Ok(Err(ProcessGroupError::NotControllingTerminal)) + ); + assert_eq!( + super::set_foreground_group(process, terminal, root.id().into()), + Ok(Err(ProcessGroupError::NotControllingTerminal)) + ); + } + // Only an existing group in the session can be in the foreground. + for (process_group, error) in [ + (detached.id().into(), ProcessGroupError::NotInSession), + (other_root.id().into(), ProcessGroupError::NotInSession), + ( + ProcessGroupId(u32::MAX), + ProcessGroupError::NoSuchProcessGroup, + ), + ] { + assert_eq!( + super::set_foreground_group(&root, root_tty, process_group), + Ok(Err(error)) + ); + } + assert_eq!( + super::set_foreground_group(&job, job_tty, job.id().into()), + Ok(Ok(())) + ); + assert_eq!( + super::get_foreground_group(&root, root_tty), + Ok(Ok(membership(&job, &root))) + ); + + let handles = [&root, &job].map(|process| { + crate::signal::open(process, Arc::new(TestReadinessSink::default())).unwrap() + }); + stdio.post_terminal_events(TerminalEvents::INTERRUPT); + assert_eq!( + crate::signal::take(&job, handles[1]), + Ok(SignalEvent::Terminal(TerminalEvents::INTERRUPT)) + ); + assert_eq!( + crate::signal::take(&root, handles[0]), + Err(BrokerError::WouldBlock) + ); + } + + #[test] + fn the_foreground_group_keeps_its_id_while_in_the_foreground() { + let (mut broker, _stdio) = terminal_broker(); + broker.ids = Arc::new(spin::Mutex::new(IdAllocator::new(3).unwrap())); + let allocate = |parent: Option<&BrokerProcess>| { + broker.allocate_process( + CallerCredential::Unauthenticated, + parent.map(BrokerProcess::id), + ) + }; + let root = allocate(None).unwrap(); + root.complete_start().unwrap(); + broker.terminal.as_ref().unwrap().attach(&root); + let tty = open(&root, "/dev/tty", FileOpenFlags::NONE); + let job = allocate(Some(&root)).unwrap(); + job.complete_start().unwrap(); + let job_id = job.id(); + super::set(&root, job_id, job_id.into()).unwrap(); + assert_eq!( + super::set_foreground_group(&root, tty, job_id.into()), + Ok(Ok(())) + ); + job.retire(true); + job.complete_exit(ProcessExitStatus::Exited { code: 0 }) + .unwrap(); + drop(job); + root.collect_child_exit(ChildSelector::Process(job_id)) + .unwrap(); + let other = allocate(None).unwrap(); + + // The exited job's group stays in the foreground until the session + // moves another group there. + assert_eq!( + allocate(None).map(|process| process.id()), + Err(BrokerError::ResourceExhausted) + ); + assert_eq!( + super::set_foreground_group(&root, tty, root.id().into()), + Ok(Ok(())) + ); + assert_eq!(allocate(None).unwrap().id(), job_id); + drop(other); + } } diff --git a/litebox_broker_core/src/terminal.rs b/litebox_broker_core/src/terminal.rs index 136d5e522f..dedc09ba20 100644 --- a/litebox_broker_core/src/terminal.rs +++ b/litebox_broker_core/src/terminal.rs @@ -53,7 +53,8 @@ pub trait TerminalEndpoint: ReadinessSource { /// return `WouldBlock` and later publish writable readiness. fn control(&self, operation: TerminalOperation) -> crate::Result>; - /// Attaches the initial process group once, ignoring later attachments. + /// Attaches the sink for the terminal's events once, ignoring later + /// attachments. fn attach(&self, sink: Arc); } diff --git a/litebox_broker_host/src/lib.rs b/litebox_broker_host/src/lib.rs index 5a3dd47b80..d582cf6b4a 100644 --- a/litebox_broker_host/src/lib.rs +++ b/litebox_broker_host/src/lib.rs @@ -1924,6 +1924,28 @@ fn handle_process_group_request( litebox_broker_core::process_group::create_session(process, process_id) .map(|()| ProcessGroupResponse::CreateSession) } + ProcessGroupRequest::GetForegroundGroup(terminal) => { + litebox_broker_core::process_group::get_foreground_group(process, terminal).map( + |result| { + result.map_or_else( + ProcessGroupResponse::Failed, + ProcessGroupResponse::GetForegroundGroup, + ) + }, + ) + } + ProcessGroupRequest::SetForegroundGroup(request) => { + litebox_broker_core::process_group::set_foreground_group( + process, + request.terminal, + request.process_group, + ) + .map(|result| { + result.map_or_else(ProcessGroupResponse::Failed, |()| { + ProcessGroupResponse::SetForegroundGroup + }) + }) + } }; response.map_err(RequestFailure::from) } diff --git a/litebox_broker_local/src/process_group.rs b/litebox_broker_local/src/process_group.rs index fe11076a38..543b143718 100644 --- a/litebox_broker_local/src/process_group.rs +++ b/litebox_broker_local/src/process_group.rs @@ -4,12 +4,16 @@ use litebox_broker_protocol::message::{ BrokerOperation, BrokerResult, ProcessGroupRequest, ProcessGroupResponse, }; -use litebox_broker_protocol::process_group::SetProcessGroupRequest; -use litebox_broker_protocol::{ProcessGroupId, ProcessId}; +use litebox_broker_protocol::process_group::{ + ProcessGroupError, ProcessGroupMembership, SetForegroundGroupRequest, SetProcessGroupRequest, +}; +use litebox_broker_protocol::{ObjectHandle, ProcessGroupId, ProcessId}; use litebox_broker_transport::channel::LocalCallChannel; use crate::{BrokerLocal, BrokerLocalError, Result}; +type ProcessGroupResult = core::result::Result; + impl BrokerLocal { /// Moves process `process_id` into `process_group`. /// @@ -27,9 +31,7 @@ impl BrokerLocal { process_group, }))? { ProcessGroupResponse::Set => Ok(()), - response @ ProcessGroupResponse::CreateSession => { - panic!("broker returned unexpected process group response: {response:?}") - } + response => panic!("broker returned unexpected process group response: {response:?}"), } } @@ -43,9 +45,48 @@ impl BrokerLocal { pub fn create_session(&self, process_id: ProcessId) -> Result<(), Channel::Error> { match self.request_process_group(ProcessGroupRequest::CreateSession(process_id))? { ProcessGroupResponse::CreateSession => Ok(()), - response @ ProcessGroupResponse::Set => { - panic!("broker returned unexpected process group response: {response:?}") - } + response => panic!("broker returned unexpected process group response: {response:?}"), + } + } + + /// Returns the foreground process group of the caller's session and the + /// session, given its controlling terminal `terminal`. + /// + /// # Panics + /// + /// Panics if the broker reports an unrecoverable error or returns a protocol + /// response that does not match the issued process group request. + pub fn get_foreground_group( + &self, + terminal: ObjectHandle, + ) -> Result, Channel::Error> { + match self.request_process_group(ProcessGroupRequest::GetForegroundGroup(terminal))? { + ProcessGroupResponse::GetForegroundGroup(membership) => Ok(Ok(membership)), + ProcessGroupResponse::Failed(error) => Ok(Err(error)), + response => panic!("broker returned unexpected process group response: {response:?}"), + } + } + + /// Moves `process_group` of the caller's session into the foreground of + /// its controlling terminal `terminal`. + /// + /// # Panics + /// + /// Panics if the broker reports an unrecoverable error or returns a protocol + /// response that does not match the issued process group request. + pub fn set_foreground_group( + &self, + terminal: ObjectHandle, + process_group: ProcessGroupId, + ) -> Result, Channel::Error> { + let request = ProcessGroupRequest::SetForegroundGroup(SetForegroundGroupRequest { + terminal, + process_group, + }); + match self.request_process_group(request)? { + ProcessGroupResponse::SetForegroundGroup => Ok(Ok(())), + ProcessGroupResponse::Failed(error) => Ok(Err(error)), + response => panic!("broker returned unexpected process group response: {response:?}"), } } diff --git a/litebox_broker_protocol/src/message.rs b/litebox_broker_protocol/src/message.rs index f258a5b0c0..6c5188f061 100644 --- a/litebox_broker_protocol/src/message.rs +++ b/litebox_broker_protocol/src/message.rs @@ -25,7 +25,9 @@ use crate::process::{ StartChildProcessRequest, StartChildWithExistingRunnerRequest, ThreadInfo, WriteChildMemoryRequest, }; -use crate::process_group::SetProcessGroupRequest; +use crate::process_group::{ + ProcessGroupError, ProcessGroupMembership, SetForegroundGroupRequest, SetProcessGroupRequest, +}; use crate::readiness::ReadinessFlags; use crate::readiness_queue::{ AddReadinessQueueEntryRequest, CreateReadinessQueueResponse, ModifyReadinessQueueEntryRequest, @@ -380,6 +382,21 @@ pub enum ProcessGroupRequest { /// returns `UnknownObject`. The broker returns `PolicyDenied` if a process /// group already has the process's ID. CreateSession(ProcessId), + /// Read the foreground process group of the caller's controlling terminal, + /// given an open handle to it. + /// + /// Fails with [`ProcessGroupError::NotControllingTerminal`] if the handle + /// does not refer to the caller's controlling terminal. + GetForegroundGroup(ObjectHandle), + /// Make a process group the foreground group of the caller's controlling + /// terminal. + /// + /// Fails with [`ProcessGroupError::NotControllingTerminal`] if the handle + /// does not refer to the caller's controlling terminal, then with + /// [`ProcessGroupError::NoSuchProcessGroup`] if no process is in the group, + /// and then with [`ProcessGroupError::NotInSession`] if the group is in + /// another session. + SetForegroundGroup(SetForegroundGroupRequest), } /// Request about signals sent between broker processes. @@ -615,6 +632,14 @@ pub enum ProcessGroupResponse { Set, /// The session was created. CreateSession, + /// The foreground process group, in the terminal's session. + GetForegroundGroup(ProcessGroupMembership), + /// The foreground process group changed. + SetForegroundGroup, + /// A foreground group operation failed in a way the guest ABI reports. + /// + /// Other failures use [`BrokerResult::Error`]. + Failed(ProcessGroupError), } /// Response to a signal request. diff --git a/litebox_broker_protocol/src/process_group.rs b/litebox_broker_protocol/src/process_group.rs index 40af614195..8a17331256 100644 --- a/litebox_broker_protocol/src/process_group.rs +++ b/litebox_broker_protocol/src/process_group.rs @@ -7,8 +7,13 @@ //! session. Each is identified by the ID of the process that created it, which //! need not still exist. A child process starts in its creator's group and //! session, while a root process, which has no creator, leads its own. +//! +//! A session may have a controlling terminal, whose events go to the session's +//! foreground process group. + +use thiserror::Error; -use crate::{ProcessGroupId, ProcessId, SessionId}; +use crate::{ObjectHandle, ProcessGroupId, ProcessId, SessionId}; /// A process's group and session. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -32,3 +37,25 @@ pub struct SetProcessGroupRequest { /// Group to join, which is created if its ID is `process_id`. pub process_group: ProcessGroupId, } + +/// Request to make a process group the foreground group of the caller's +/// controlling terminal. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SetForegroundGroupRequest { + /// Open handle to the caller's controlling terminal. + pub terminal: ObjectHandle, + /// Group to move to the foreground, which must be in the caller's session. + pub process_group: ProcessGroupId, +} + +/// Process group operation failure that is meaningful to the guest ABI. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum ProcessGroupError { + #[error("object is not the caller's controlling terminal")] + NotControllingTerminal, + #[error("no such process group")] + NoSuchProcessGroup, + #[error("process group is not in the caller's session")] + NotInSession, +} diff --git a/litebox_broker_protocol/src/terminal.rs b/litebox_broker_protocol/src/terminal.rs index 114f2c4d38..28255d3c8e 100644 --- a/litebox_broker_protocol/src/terminal.rs +++ b/litebox_broker_protocol/src/terminal.rs @@ -193,11 +193,11 @@ pub enum TerminalResponse { } bitflags::bitflags! { - /// Terminal actions delivered to the initially attached process group. + /// Terminal actions delivered to the terminal's foreground process group. /// - /// Repeated actions coalesce. These are not guest signal numbers. - /// Foreground-group switching and stop/continue are deliberately outside - /// this initial terminal interface. + /// Repeated actions coalesce. These are not guest signal numbers. Stopping + /// and continuing jobs, and restricting background groups' access to the + /// terminal, are outside this interface. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct TerminalEvents: u8 { /// The interrupt character was received with action generation enabled. diff --git a/litebox_broker_protocol/src/wire.rs b/litebox_broker_protocol/src/wire.rs index cff28306a6..e514db53a9 100644 --- a/litebox_broker_protocol/src/wire.rs +++ b/litebox_broker_protocol/src/wire.rs @@ -650,7 +650,10 @@ mod tests { ProcessExitStatus, ProcessIdentity, ProcessInfo, StartChildProcessRequest, StartChildWithExistingRunnerRequest, ThreadInfo, WriteChildMemoryRequest, }; - use crate::process_group::{ProcessGroupMembership, SetProcessGroupRequest}; + use crate::process_group::{ + ProcessGroupError, ProcessGroupMembership, SetForegroundGroupRequest, + SetProcessGroupRequest, + }; use crate::readiness_queue::{ AddReadinessQueueEntryRequest, CreateReadinessQueueResponse, ModifyReadinessQueueEntryRequest, ReadinessQueueEntryFlags, ReadinessQueueError, @@ -884,6 +887,13 @@ mod tests { process_group: ProcessGroupId(u32::MAX), })), BrokerOperation::ProcessGroup(ProcessGroupRequest::CreateSession(process_id(5))), + BrokerOperation::ProcessGroup(ProcessGroupRequest::GetForegroundGroup(handle)), + BrokerOperation::ProcessGroup(ProcessGroupRequest::SetForegroundGroup( + SetForegroundGroupRequest { + terminal: handle, + process_group: ProcessGroupId(u32::MAX), + }, + )), BrokerOperation::Signal(SignalRequest::Take(TakeSignalRequest { handle })), BrokerOperation::Pipe(PipeRequest::Create(CreatePipeRequest { capacity: 4096, @@ -1581,6 +1591,22 @@ mod tests { ))), BrokerResult::ProcessGroup(ProcessGroupResponse::Set), BrokerResult::ProcessGroup(ProcessGroupResponse::CreateSession), + BrokerResult::ProcessGroup(ProcessGroupResponse::GetForegroundGroup( + ProcessGroupMembership { + process_group: ProcessGroupId(3), + session: SessionId(u32::MAX), + }, + )), + BrokerResult::ProcessGroup(ProcessGroupResponse::SetForegroundGroup), + BrokerResult::ProcessGroup(ProcessGroupResponse::Failed( + ProcessGroupError::NotControllingTerminal, + )), + BrokerResult::ProcessGroup(ProcessGroupResponse::Failed( + ProcessGroupError::NoSuchProcessGroup, + )), + BrokerResult::ProcessGroup(ProcessGroupResponse::Failed( + ProcessGroupError::NotInSession, + )), BrokerResult::Pipe(PipeResponse::Create(CreatePipeResponse { read_handle: handle, write_handle: ObjectHandle(14), @@ -2376,6 +2402,23 @@ mod tests { assert_eq!(decode_response(&frame), Err(WireError::InvalidTag)); } + let process_group_error = encode_response(BrokerResponse { + request_id: TEST_REQUEST_ID, + result: BrokerResult::ProcessGroup(ProcessGroupResponse::Failed( + ProcessGroupError::NotInSession, + )), + }); + let mut unknown_process_group_error = process_group_error.clone(); + *unknown_process_group_error.last_mut().unwrap() = 3; + assert_eq!( + decode_response(&unknown_process_group_error), + Err(WireError::InvalidTag) + ); + assert_eq!( + decode_response(&process_group_error[..process_group_error.len() - 1]), + Err(WireError::TruncatedFrame) + ); + let socket_error = encode_response(BrokerResponse { request_id: TEST_REQUEST_ID, result: BrokerResult::Socket(SocketResponse::Failed(SocketError::TimedOut)), diff --git a/litebox_broker_protocol/src/wire/process_group.rs b/litebox_broker_protocol/src/wire/process_group.rs index 117e3c8c06..5646bf057e 100644 --- a/litebox_broker_protocol/src/wire/process_group.rs +++ b/litebox_broker_protocol/src/wire/process_group.rs @@ -2,16 +2,27 @@ // Licensed under the MIT license. use crate::message::{ProcessGroupRequest, ProcessGroupResponse}; -use crate::process_group::SetProcessGroupRequest; +use crate::process_group::{ + ProcessGroupError, ProcessGroupMembership, SetForegroundGroupRequest, SetProcessGroupRequest, +}; use super::WireError; use super::primitive::{Decoder, Encoder}; const PROCESS_GROUP_REQUEST_TAG_SET: u8 = 0; const PROCESS_GROUP_REQUEST_TAG_CREATE_SESSION: u8 = 1; +const PROCESS_GROUP_REQUEST_TAG_GET_FOREGROUND_GROUP: u8 = 2; +const PROCESS_GROUP_REQUEST_TAG_SET_FOREGROUND_GROUP: u8 = 3; const PROCESS_GROUP_RESPONSE_TAG_SET: u8 = 0; const PROCESS_GROUP_RESPONSE_TAG_CREATE_SESSION: u8 = 1; +const PROCESS_GROUP_RESPONSE_TAG_GET_FOREGROUND_GROUP: u8 = 2; +const PROCESS_GROUP_RESPONSE_TAG_SET_FOREGROUND_GROUP: u8 = 3; +const PROCESS_GROUP_RESPONSE_TAG_FAILED: u8 = 4; + +const PROCESS_GROUP_ERROR_TAG_NOT_CONTROLLING_TERMINAL: u8 = 0; +const PROCESS_GROUP_ERROR_TAG_NO_SUCH_PROCESS_GROUP: u8 = 1; +const PROCESS_GROUP_ERROR_TAG_NOT_IN_SESSION: u8 = 2; pub(super) fn encode_process_group_request(encoder: &mut Encoder, request: ProcessGroupRequest) { match request { @@ -24,6 +35,15 @@ pub(super) fn encode_process_group_request(encoder: &mut Encoder, request: Proce encoder.u8(PROCESS_GROUP_REQUEST_TAG_CREATE_SESSION); encoder.process_id(process_id); } + ProcessGroupRequest::GetForegroundGroup(terminal) => { + encoder.u8(PROCESS_GROUP_REQUEST_TAG_GET_FOREGROUND_GROUP); + encoder.handle(terminal); + } + ProcessGroupRequest::SetForegroundGroup(request) => { + encoder.u8(PROCESS_GROUP_REQUEST_TAG_SET_FOREGROUND_GROUP); + encoder.handle(request.terminal); + encoder.process_group_id(request.process_group); + } } } @@ -38,6 +58,15 @@ pub(super) fn decode_process_group_request( PROCESS_GROUP_REQUEST_TAG_CREATE_SESSION => { ProcessGroupRequest::CreateSession(decoder.process_id()?) } + PROCESS_GROUP_REQUEST_TAG_GET_FOREGROUND_GROUP => { + ProcessGroupRequest::GetForegroundGroup(decoder.handle()?) + } + PROCESS_GROUP_REQUEST_TAG_SET_FOREGROUND_GROUP => { + ProcessGroupRequest::SetForegroundGroup(SetForegroundGroupRequest { + terminal: decoder.handle()?, + process_group: decoder.process_group_id()?, + }) + } _ => return Err(WireError::InvalidTag), }) } @@ -48,6 +77,26 @@ pub(super) fn encode_process_group_response(encoder: &mut Encoder, response: Pro ProcessGroupResponse::CreateSession => { encoder.u8(PROCESS_GROUP_RESPONSE_TAG_CREATE_SESSION); } + ProcessGroupResponse::GetForegroundGroup(foreground) => { + encoder.u8(PROCESS_GROUP_RESPONSE_TAG_GET_FOREGROUND_GROUP); + encoder.process_group_id(foreground.process_group); + encoder.session_id(foreground.session); + } + ProcessGroupResponse::SetForegroundGroup => { + encoder.u8(PROCESS_GROUP_RESPONSE_TAG_SET_FOREGROUND_GROUP); + } + ProcessGroupResponse::Failed(error) => { + encoder.u8(PROCESS_GROUP_RESPONSE_TAG_FAILED); + encoder.u8(match error { + ProcessGroupError::NotControllingTerminal => { + PROCESS_GROUP_ERROR_TAG_NOT_CONTROLLING_TERMINAL + } + ProcessGroupError::NoSuchProcessGroup => { + PROCESS_GROUP_ERROR_TAG_NO_SUCH_PROCESS_GROUP + } + ProcessGroupError::NotInSession => PROCESS_GROUP_ERROR_TAG_NOT_IN_SESSION, + }); + } } } @@ -57,6 +106,21 @@ pub(super) fn decode_process_group_response( Ok(match decoder.u8()? { PROCESS_GROUP_RESPONSE_TAG_SET => ProcessGroupResponse::Set, PROCESS_GROUP_RESPONSE_TAG_CREATE_SESSION => ProcessGroupResponse::CreateSession, + PROCESS_GROUP_RESPONSE_TAG_GET_FOREGROUND_GROUP => { + ProcessGroupResponse::GetForegroundGroup(ProcessGroupMembership { + process_group: decoder.process_group_id()?, + session: decoder.session_id()?, + }) + } + PROCESS_GROUP_RESPONSE_TAG_SET_FOREGROUND_GROUP => ProcessGroupResponse::SetForegroundGroup, + PROCESS_GROUP_RESPONSE_TAG_FAILED => ProcessGroupResponse::Failed(match decoder.u8()? { + PROCESS_GROUP_ERROR_TAG_NOT_CONTROLLING_TERMINAL => { + ProcessGroupError::NotControllingTerminal + } + PROCESS_GROUP_ERROR_TAG_NO_SUCH_PROCESS_GROUP => ProcessGroupError::NoSuchProcessGroup, + PROCESS_GROUP_ERROR_TAG_NOT_IN_SESSION => ProcessGroupError::NotInSession, + _ => return Err(WireError::InvalidTag), + }), _ => return Err(WireError::InvalidTag), }) } diff --git a/litebox_common_linux/src/errno/mod.rs b/litebox_common_linux/src/errno/mod.rs index 47879e0811..2972959432 100644 --- a/litebox_common_linux/src/errno/mod.rs +++ b/litebox_common_linux/src/errno/mod.rs @@ -148,7 +148,10 @@ impl From for Errno { litebox::process::ProcessError::OutOfMemory => Errno::ENOMEM, litebox::process::ProcessError::ServiceFailed | litebox::process::ProcessError::InvalidChild => Errno::EIO, - litebox::process::ProcessError::NoSuchProcess => Errno::ESRCH, + litebox::process::ProcessError::NoSuchProcess + | litebox::process::ProcessError::NoSuchProcessGroup => Errno::ESRCH, + litebox::process::ProcessError::NotControllingTerminal => Errno::ENOTTY, + litebox::process::ProcessError::NotInSession => Errno::EPERM, } } } @@ -874,6 +877,9 @@ mod tests { (ProcessError::ServiceFailed, Errno::EIO), (ProcessError::InvalidChild, Errno::EIO), (ProcessError::NoSuchProcess, Errno::ESRCH), + (ProcessError::NotControllingTerminal, Errno::ENOTTY), + (ProcessError::NoSuchProcessGroup, Errno::ESRCH), + (ProcessError::NotInSession, Errno::EPERM), ]; for (error, expected) in cases { diff --git a/litebox_common_linux/src/lib.rs b/litebox_common_linux/src/lib.rs index e1382d9a8a..2b19f14096 100644 --- a/litebox_common_linux/src/lib.rs +++ b/litebox_common_linux/src/lib.rs @@ -1132,9 +1132,12 @@ pub const TCSETSW: u32 = 0x5403; pub const TCSETSF: u32 = 0x5404; pub const TCSBRK: u32 = 0x5409; pub const TCFLSH: u32 = 0x540b; +pub const TIOCGPGRP: u32 = 0x540f; +pub const TIOCSPGRP: u32 = 0x5410; pub const TIOCGWINSZ: u32 = 0x5413; pub const TIOCSWINSZ: u32 = 0x5414; pub const FIONBIO: u32 = 0x5421; +pub const TIOCGSID: u32 = 0x5429; pub const TCGETS2: u32 = 0x802c542a; pub const TCSETS2: u32 = 0x402c542b; pub const TCSETSW2: u32 = 0x402c542c; @@ -1170,6 +1173,12 @@ pub enum IoctlArg { TIOCGWINSZ(UserPtrMut), /// Set window size. TIOCSWINSZ(UserPtr), + /// Get the terminal's foreground process group. + TIOCGPGRP(UserPtrMut), + /// Set the terminal's foreground process group. + TIOCSPGRP(UserPtr), + /// Get the session the terminal controls. + TIOCGSID(UserPtrMut), /// Obtain device unit number, which can be used to generate /// the filename of the pseudo-terminal slave device. TIOCGPTN(UserPtrMut), @@ -3147,6 +3156,9 @@ impl SyscallRequest { TCFLSH => IoctlArg::TCFLSH(ctx.sys_req_arg(2)), TIOCGWINSZ => IoctlArg::TIOCGWINSZ(ctx.sys_req_ptr(2)), TIOCSWINSZ => IoctlArg::TIOCSWINSZ(ctx.sys_req_ptr(2)), + TIOCGPGRP => IoctlArg::TIOCGPGRP(ctx.sys_req_ptr(2)), + TIOCSPGRP => IoctlArg::TIOCSPGRP(ctx.sys_req_ptr(2)), + TIOCGSID => IoctlArg::TIOCGSID(ctx.sys_req_ptr(2)), TIOCGPTN => IoctlArg::TIOCGPTN(ctx.sys_req_ptr(2)), FIONBIO => IoctlArg::FIONBIO(ctx.sys_req_ptr(2)), FIOCLEX => IoctlArg::FIOCLEX, diff --git a/litebox_runner_linux_userland/tests/run.rs b/litebox_runner_linux_userland/tests/run.rs index d8aa1cea1f..159b07e938 100644 --- a/litebox_runner_linux_userland/tests/run.rs +++ b/litebox_runner_linux_userland/tests/run.rs @@ -2943,6 +2943,29 @@ fn check_broker_tty_line_discipline(alias: bool) { ); } +/// The session moves the terminal's events to its foreground process group. +#[cfg(target_os = "linux")] +#[test] +fn test_runner_broker_tty_job_control() { + let target = common::compile("./tests/tty.c", "tty_jobs", true, false); + let mut runner = Runner::new(&target, "tty_jobs"); + runner.allow_process_duplication().arg("jobs"); + let mut pty = common::pty::Pty::open(); + let (stdin, stdout, stderr) = pty.slave_stdio(); + let mut child = + runner.spawn_with_controlling_terminal(pty.slave(), stdin, stdout, stderr, false); + let mut output = Vec::new(); + pty.wait_for_output(&mut output, b"JOB READY"); + pty.write_all(b"\x03"); + let status = pty.wait_for_child_exit(&mut child, &mut output); + assert!( + status.success(), + "job-control TTY fixture failed with {status}:\n{}", + String::from_utf8_lossy(&output) + ); + assert!(output.windows(9).any(|bytes| bytes == b"JOBS DONE")); +} + /// A piped standard stream leaves the terminal to the host: the guest has none. #[cfg(target_os = "linux")] #[test] diff --git a/litebox_runner_linux_userland/tests/tty.c b/litebox_runner_linux_userland/tests/tty.c index c9b1f5e913..45856ed894 100644 --- a/litebox_runner_linux_userland/tests/tty.c +++ b/litebox_runner_linux_userland/tests/tty.c @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -102,6 +103,50 @@ int main(int argc, char **argv) { return 0; } + if (strcmp(mode, "jobs") == 0) { + // The session moves the terminal's events to its foreground process group. Like a shell, + // ignore SIGTTOU, which Linux sends a background group that sets the foreground. An + // interrupt reaching this process must kill it, whatever it inherited. + assert(signal(SIGTTOU, SIG_IGN) != SIG_ERR && signal(SIGINT, SIG_DFL) != SIG_ERR); + sigset_t interrupt; + sigemptyset(&interrupt); + sigaddset(&interrupt, SIGINT); + assert(sigprocmask(SIG_UNBLOCK, &interrupt, NULL) == 0); + pid_t self = getpid(); + assert(tcgetpgrp(0) == self && tcgetsid(0) == self); + assert(tcsetpgrp(0, INT_MAX) == -1 && errno == ESRCH); + int ready[2]; + assert(pipe(ready) == 0); + assert(tcgetpgrp(ready[0]) == -1 && errno == ENOTTY); + int status; + pid_t outsider = fork(); + assert(outsider >= 0); + if (outsider == 0) { + // A new session has no controlling terminal. + _exit(setsid() > 0 && tcgetpgrp(0) == -1 && errno == ENOTTY ? 0 : 1); + } + assert(waitpid(outsider, &status, 0) == outsider && WIFEXITED(status) && WEXITSTATUS(status) == 0); + pid_t job = fork(); + assert(job >= 0); + if (job == 0) { + struct sigaction action = {.sa_handler = on_signal}; + sigemptyset(&action.sa_mask); + assert(sigaction(SIGINT, &action, NULL) == 0); + assert(setpgid(0, 0) == 0); + assert(write(ready[1], "", 1) == 1); + await_signal(&interrupts); + _exit(0); + } + assert(read(ready[0], bytes, 1) == 1); + assert(tcsetpgrp(0, job) == 0 && tcgetpgrp(1) == job && tcgetsid(2) == self); + // The interrupt reaches only the job. + puts("JOB READY"); + assert(waitpid(job, &status, 0) == job && WIFEXITED(status) && WEXITSTATUS(status) == 0); + assert(tcsetpgrp(0, self) == 0 && tcgetpgrp(0) == self); + puts("JOBS DONE"); + return 0; + } + assert(isatty(0) && isatty(2)); assert(tcgetattr(0, &original) == 0); assert(original.c_lflag & ICANON); diff --git a/litebox_shim_linux/src/stdio.rs b/litebox_shim_linux/src/stdio.rs index 7814eb8353..69ac45471f 100644 --- a/litebox_shim_linux/src/stdio.rs +++ b/litebox_shim_linux/src/stdio.rs @@ -362,6 +362,68 @@ mod tests { assert_eq!(buf[0], b'x'); } + #[test] + fn test_job_control_uses_the_controlling_terminal() { + let task = init_platform(); + let pid = task.sys_getpid(); + let tty = i32::try_from( + task.sys_open("/dev/tty", OFlags::RDWR, Mode::empty()) + .unwrap(), + ) + .unwrap(); + // The first process's group starts in the foreground of its session's terminal. + for fd in [1, tty] { + for request in [IoctlArg::TIOCGPGRP, IoctlArg::TIOCGSID] { + let mut id = 0; + assert_eq!( + task.sys_ioctl(fd, request(UserPtrMut::from_ptr(&raw mut id))), + Ok(0) + ); + assert_eq!(id, pid); + } + } + // Only an existing group in the session can move to the foreground. + for (process_group, expected) in [ + (pid, Ok(0)), + (i32::MAX, Err(Errno::ESRCH)), + (-1, Err(Errno::EINVAL)), + ] { + assert_eq!( + task.sys_ioctl( + tty, + IoctlArg::TIOCSPGRP(UserPtr::from_ptr(&raw const process_group)) + ), + expected + ); + } + // Like Linux, the descriptor is checked before the request. + let (pipe, _writer) = task.sys_pipe2(OFlags::empty()).unwrap(); + let path = i32::try_from( + task.sys_open("/dev/stdout", OFlags::PATH, Mode::empty()) + .unwrap(), + ) + .unwrap(); + let invalid = -1; + for (fd, expected) in [ + (0, Errno::ENOTTY), + (i32::try_from(pipe).unwrap(), Errno::ENOTTY), + (path, Errno::EBADF), + ] { + let mut id = 0; + assert_eq!( + task.sys_ioctl(fd, IoctlArg::TIOCGPGRP(UserPtrMut::from_ptr(&raw mut id))), + Err(expected) + ); + assert_eq!( + task.sys_ioctl( + fd, + IoctlArg::TIOCSPGRP(UserPtr::from_ptr(&raw const invalid)) + ), + Err(expected) + ); + } + } + #[test] fn test_unknown_ioctl_fails_with_enotty() { let task = init_platform(); diff --git a/litebox_shim_linux/src/syscalls/file.rs b/litebox_shim_linux/src/syscalls/file.rs index e2353fb788..4171acb498 100644 --- a/litebox_shim_linux/src/syscalls/file.rs +++ b/litebox_shim_linux/src/syscalls/file.rs @@ -16,11 +16,10 @@ use litebox::{ }, fd::{FdEnabledSubsystem, FdEnabledSubsystemEntry, TypedFd}, fs::errors::OpenError, - process::InheritableFd, + process::{InheritableFd, ProcessError}, sync::Mutex, utils::{ReinterpretSignedExt as _, ReinterpretUnsignedExt as _, TruncateExt as _}, }; -use litebox_broker_protocol::ObjectHandle; use litebox_broker_protocol::fs::{ FileAccessMode, FileMode as Mode, FileOpenFlags, FileSeekWhence as SeekWhence, FileStatus, FileStatusFlags, FileType, FileUser, @@ -28,6 +27,7 @@ use litebox_broker_protocol::fs::{ use litebox_broker_protocol::terminal::{ ChangeTiming, TerminalOperation, TerminalResponse, TerminalSettings, }; +use litebox_broker_protocol::{ObjectHandle, ProcessGroupId}; use litebox_common_linux::{ AccessFlags, AtFlags, CloseRangeFlags, EfdFlags, EpollCreateFlags, FcntlArg, FileDescriptorFlags, FileStat, InodeType, IoReadVec, IoWriteVec, IoctlArg, OFlags, PAGE_SIZE, @@ -2615,6 +2615,52 @@ impl Task { Ok(0) } + /// Handles a job-control ioctl, which `file` must be the controlling terminal for. + fn job_control_ioctl(&self, file: &File, arg: &IoctlArg) -> Result { + // Like Linux, rejects the descriptor (`EBADF` for `O_PATH`, `ENOTTY` for a non-terminal) + // before reporting `error`. + let reject = |error: Errno| { + file.terminal_control(TerminalOperation::GetSettings, None) + .err() + .unwrap_or(error) + }; + let process_error = |error: ProcessError| match error { + ProcessError::NotControllingTerminal => reject(Errno::ENOTTY), + error => error.into(), + }; + let litebox = &self.global.litebox; + match arg { + IoctlArg::TIOCGPGRP(ptr) | IoctlArg::TIOCGSID(ptr) => { + let foreground = litebox + .get_foreground_group(file.file()) + .map_err(process_error)?; + let id = if matches!(arg, IoctlArg::TIOCGPGRP(_)) { + foreground.process_group.0 + } else { + foreground.session.0 + }; + ptr.write_at_offset::(0, id.cast_signed()) + .ok_or(Errno::EFAULT)?; + } + IoctlArg::TIOCSPGRP(ptr) => { + let process_group = ptr + .read_at_offset::(0) + .ok_or_else(|| reject(Errno::EFAULT))?; + if process_group < 0 { + return Err(reject(Errno::EINVAL)); + } + litebox + .set_foreground_group( + file.file(), + ProcessGroupId(process_group.cast_unsigned()), + ) + .map_err(process_error)?; + } + _ => unreachable!("non-job-control ioctl"), + } + Ok(0) + } + /// Handle syscall `ioctl` pub fn sys_ioctl(&self, fd: i32, arg: IoctlArg) -> Result { let files = self.files.borrow(); @@ -2705,6 +2751,12 @@ impl Task { self.global .with_file(fd, |file| self.terminal_ioctl(file, &arg)) } + IoctlArg::TIOCGPGRP(..) | IoctlArg::TIOCSPGRP(..) | IoctlArg::TIOCGSID(..) => { + let fd = files.typed_fd(fd)?; + let fd = fd.file_only(Errno::ENOTTY)?; + self.global + .with_file(fd, |file| self.job_control_ioctl(file, &arg)) + } _ => { log_unsupported!("ioctl with arg {:?}", arg); // Like Linux, an unknown request fails with `ENOTTY` on any usable descriptor. diff --git a/litebox_shim_linux/src/syscalls/test_broker.rs b/litebox_shim_linux/src/syscalls/test_broker.rs index d5a162826e..b27f9c0e3e 100644 --- a/litebox_shim_linux/src/syscalls/test_broker.rs +++ b/litebox_shim_linux/src/syscalls/test_broker.rs @@ -21,7 +21,7 @@ use litebox_broker_core::{ Ipv4Cidr, ObjectRights, PolicyEngine, SocketPolicy, fs::{in_mem::InitialNode, resolver::Resolver}, socket::HOST_GATEWAY_IPV4_ADDRESS, - stdio::StdioStream, + stdio::{StdioProvider as _, StdioStream}, test_support::{FailingRandomProvider, TestBrokerCoreBuilder, TestStdioProvider}, }; use litebox_broker_host::{ @@ -150,7 +150,8 @@ fn test_broker(limits: BrokerCoreLimits) -> &'static TestBroker { let builder = builder.with_socket_provider(Arc::new(socket_provider)); let core = builder .build() - .expect("a test process may build only one broker core"); + .expect("a test process may build only one broker core") + .with_terminal(stdio.terminal().expect("the test terminal must exist")); TestBroker { core, stdio } }) }