blob: 63cbbfeaaba547dd9f69ab6e4e097811abb0635b [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! Live shell ownership. Durable records remain in the existing EventLog.
mod control;
mod handle;
mod output;
mod pipes;
pub mod request;
pub mod stdin;
mod terminal;
mod worker;
pub use control::Input as ControlInput;
pub use control::{ControlError, ControlErrorKind, WriteReceipt};
pub use handle::{ShellHandle, StopReceipt};
pub use output::{PtyStream, PtyStreamEvent};
use maka_agent_facts::shell_run::{ShellObservation, ShellRun};
use maka_agent_facts::terminal::TerminalSize;
use maka_event_log::{EventLog, StoreError};
use maka_process::pty::PtyCommand;
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use tokio::sync::{mpsc, watch};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
#[derive(Debug, thiserror::Error)]
pub enum ShellError {
#[error("shell launch cancelled before admission")]
CancelledBeforeAdmission,
#[error("{0}")]
Rejected(&'static str),
#[error(transparent)]
Store(#[from] StoreError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("PTY replay failed: {0}")]
Replay(#[source] Arc<std::io::Error>),
#[error(transparent)]
Screen(#[from] maka_process::terminal::ScreenError),
#[error(transparent)]
Process(#[from] maka_agent_facts::tools::ToolError),
}
type Result<T> = std::result::Result<T, ShellError>;
type Key = (String, String);
type Active = Arc<Mutex<HashMap<Key, ShellHandle>>>;
pub(super) type Update = Option<std::result::Result<Arc<ShellObservation>, Arc<ShellError>>>;
/// At most eight native PTYs; each worker owns its Rust terminal parser.
/// This is an internal execution API, not a client authorization boundary.
pub struct ShellResources {
log: Arc<EventLog>,
active: Active,
output_changes: watch::Sender<()>,
workers: TaskTracker,
shutdown: CancellationToken,
host_drain: CancellationToken,
}
impl ShellResources {
pub fn new(log: Arc<EventLog>, host_drain: CancellationToken) -> Self {
Self {
log,
active: Arc::default(),
output_changes: watch::channel(()).0,
workers: TaskTracker::new(),
shutdown: host_drain.child_token(),
host_drain,
}
}
/// Acceptance transfers lifetime ownership, not just the caller's future.
/// Session admission and controller authorization belong to the Host caller.
pub fn start_pty(
&self,
record: ShellRun,
command: PtyCommand,
size: TerminalSize,
) -> Result<ShellHandle> {
record.validate().map_err(ShellError::Rejected)?;
if !record.output.is_pty()
|| record
.timeout_ms
.is_some_and(|ms| maka_process::validate_timeout(ms).is_err())
{
return Err(ShellError::Rejected("invalid PTY launch"));
}
// File creation never holds the shared active-map lock.
let output = output::Output::new(size, self.output_changes.clone())?;
let mut active = self.active.lock().unwrap();
if self.shutdown.is_cancelled() {
return Err(ShellError::Rejected("shell resources are draining"));
}
let key = (record.session_id.clone(), record.id.clone());
if active.contains_key(&key) {
return Err(ShellError::Rejected("shell resource already active"));
}
if active
.values()
.filter(|handle| handle.pty.is_some())
.count()
>= 8
{
return Err(ShellError::Rejected("PTY capacity exhausted"));
}
let (commands, receiver) = mpsc::channel(8);
let (updates, snapshot) = watch::channel(None);
let cancellation = self.shutdown.child_token();
let stop = Arc::default();
let handle = ShellHandle {
control_gate: Arc::default(),
snapshot,
cancellation: cancellation.clone(),
stop: Arc::clone(&stop),
pty: Some(handle::PtyControl {
commands,
output: output.clone(),
}),
};
let owner = Residency {
key: key.clone(),
active: self.active.clone(),
_tracked: self.workers.token(),
};
let log = self.log.clone();
let host_drain = self.host_drain.clone();
active.insert(key, handle.clone());
// Release the map before spawn: failure drops Residency on this thread.
drop(active);
std::thread::Builder::new()
.name("maka-pty".into())
.spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(
worker::Worker {
log: log.clone(),
record: Arc::new(record),
commands: receiver,
updates: updates.clone(),
cancellation,
stop,
host_drain: host_drain.clone(),
persistence_failed: false,
output: output.clone(),
}
.run(command, size),
)
}));
match result {
Ok(Ok(())) => {}
Ok(Err(error)) => {
updates.send_replace(Some(Err(Arc::new(error))));
}
Err(_) => {
host_drain.cancel();
updates.send_replace(Some(Err(Arc::new(ShellError::Rejected(
"PTY worker panicked; outcome unknown",
)))));
}
}
output.close();
// OS handles and the private scheduler drain before releasing capacity.
drop(owner);
})?;
Ok(handle)
}
pub fn get(&self, session: &str, id: &str) -> Option<ShellHandle> {
self.active
.lock()
.unwrap()
.get(&(session.into(), id.into()))
.cloned()
}
/// Coalesced wakeups only; bytes and cursors remain in each shared PTY replay.
pub fn subscribe_output(&self) -> watch::Receiver<()> {
self.output_changes.subscribe()
}
/// Non-interactive background commands use bounded pipes, never a V8.
pub fn start_pipes(
&self,
record: ShellRun,
command: maka_process::Command,
) -> Result<ShellHandle> {
use futures_util::FutureExt;
record.validate().map_err(ShellError::Rejected)?;
if record.output.is_pty() {
return Err(ShellError::Rejected("invalid pipe launch"));
}
let scheduler = tokio::runtime::Handle::try_current()
.map_err(|_| ShellError::Rejected("shell launch requires a runtime"))?;
let mut active = self.active.lock().unwrap();
if self.shutdown.is_cancelled() {
return Err(ShellError::Rejected("shell resources are draining"));
}
let key = (record.session_id.clone(), record.id.clone());
if active.contains_key(&key) {
return Err(ShellError::Rejected("shell resource already active"));
}
if active
.values()
.filter(|handle| handle.pty.is_none())
.count()
>= 64
{
return Err(ShellError::Rejected("background pipe capacity exhausted"));
}
let cancellation = self.shutdown.child_token();
let process = command.observe(record.timeout_ms, cancellation.clone())?;
let (updates, snapshot) = watch::channel(None);
let stop = Arc::default();
let handle = ShellHandle {
control_gate: Arc::default(),
snapshot,
cancellation: cancellation.clone(),
stop: Arc::clone(&stop),
pty: None,
};
let owner = Residency {
key: key.clone(),
active: self.active.clone(),
_tracked: self.workers.token(),
};
let log = self.log.clone();
let drain = self.host_drain.clone();
active.insert(key, handle.clone());
drop(active);
scheduler.spawn(async move {
let _owner = owner;
let result = std::panic::AssertUnwindSafe(pipes::run(
log,
record,
process,
&updates,
&cancellation,
&drain,
&stop,
))
.catch_unwind()
.await;
let error = match result {
Ok(Ok(())) => return,
Ok(Err(error)) => error,
Err(_) => {
drain.cancel();
ShellError::Rejected("pipe worker panicked; outcome unknown")
}
};
updates.send_replace(Some(Err(Arc::new(error))));
});
Ok(handle)
}
pub fn active_count(&self) -> usize {
self.active.lock().unwrap().len()
}
pub async fn shutdown(&self) {
{
let _admission = self.active.lock().unwrap();
self.shutdown.cancel();
self.workers.close();
}
self.workers.wait().await;
}
}
impl Drop for ShellResources {
fn drop(&mut self) {
self.shutdown.cancel();
}
}
struct Residency {
key: Key,
active: Active,
_tracked: tokio_util::task::task_tracker::TaskTrackerToken,
}
impl Drop for Residency {
fn drop(&mut self) {
self.active.lock().unwrap().remove(&self.key);
}
}