Add ulimit builtin and optimize shed -c to exec single commands directly without forking

This commit is contained in:
2026-03-06 11:07:46 -05:00
parent 8a7211d42e
commit 42b4120055
6 changed files with 279 additions and 56 deletions

View File

@@ -24,13 +24,14 @@ pub mod test; // [[ ]] thing
pub mod trap;
pub mod varcmds;
pub mod zoltraak;
pub mod resource;
pub const BUILTINS: [&str; 47] = [
pub const BUILTINS: [&str; 49] = [
"echo", "cd", "read", "export", "local", "pwd", "source", "shift", "jobs", "fg", "bg", "disown",
"alias", "unalias", "return", "break", "continue", "exit", "zoltraak", "shopt", "builtin",
"command", "trap", "pushd", "popd", "dirs", "exec", "eval", "true", "false", ":", "readonly",
"unset", "complete", "compgen", "map", "pop", "fpop", "push", "fpush", "rotate", "wait", "type",
"getopts", "keymap", "read_key", "autocmd",
"getopts", "keymap", "read_key", "autocmd", "ulimit", "umask"
];
pub fn true_builtin() -> ShResult<()> {

169
src/builtin/resource.rs Normal file
View File

@@ -0,0 +1,169 @@
use ariadne::Fmt;
use nix::sys::resource::{Resource, getrlimit, setrlimit};
use yansi::Color;
use crate::{
getopt::{Opt, OptSpec, get_opts_from_tokens}, libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt, next_color}, parse::{NdRule, Node, execute::prepare_argv}, prelude::*, state::{self}
};
fn ulimit_opt_spec() -> [OptSpec;5] {
[
OptSpec {
opt: Opt::Short('n'), // file descriptors
takes_arg: true,
},
OptSpec {
opt: Opt::Short('u'), // max user processes
takes_arg: true,
},
OptSpec {
opt: Opt::Short('s'), // stack size
takes_arg: true,
},
OptSpec {
opt: Opt::Short('c'), // core dump file size
takes_arg: true,
},
OptSpec {
opt: Opt::Short('v'), // virtual memory
takes_arg: true,
}
]
}
struct UlimitOpts {
fds: Option<u64>,
procs: Option<u64>,
stack: Option<u64>,
core: Option<u64>,
vmem: Option<u64>,
}
fn get_ulimit_opts(opt: &[Opt]) -> ShResult<UlimitOpts> {
let mut opts = UlimitOpts {
fds: None,
procs: None,
stack: None,
core: None,
vmem: None,
};
for o in opt {
match o {
Opt::ShortWithArg('n', arg) => {
opts.fds = Some(arg.parse().map_err(|_| ShErr::simple(
ShErrKind::ParseErr,
format!("invalid argument for -n: {}", arg.fg(next_color())),
))?);
},
Opt::ShortWithArg('u', arg) => {
opts.procs = Some(arg.parse().map_err(|_| ShErr::simple(
ShErrKind::ParseErr,
format!("invalid argument for -u: {}", arg.fg(next_color())),
))?);
},
Opt::ShortWithArg('s', arg) => {
opts.stack = Some(arg.parse().map_err(|_| ShErr::simple(
ShErrKind::ParseErr,
format!("invalid argument for -s: {}", arg.fg(next_color())),
))?);
},
Opt::ShortWithArg('c', arg) => {
opts.core = Some(arg.parse().map_err(|_| ShErr::simple(
ShErrKind::ParseErr,
format!("invalid argument for -c: {}", arg.fg(next_color())),
))?);
},
Opt::ShortWithArg('v', arg) => {
opts.vmem = Some(arg.parse().map_err(|_| ShErr::simple(
ShErrKind::ParseErr,
format!("invalid argument for -v: {}", arg.fg(next_color())),
))?);
},
o => return Err(ShErr::simple(
ShErrKind::ParseErr,
format!("invalid option: {}", o.fg(next_color())),
)),
}
}
Ok(opts)
}
pub fn ulimit(node: Node) -> ShResult<()> {
let span = node.get_span();
let NdRule::Command {
assignments: _,
argv,
} = node.class
else {
unreachable!()
};
let (_, opts) = get_opts_from_tokens(argv, &ulimit_opt_spec()).promote_err(span.clone())?;
let ulimit_opts = get_ulimit_opts(&opts).promote_err(span.clone())?;
if let Some(fds) = ulimit_opts.fds {
let (_, hard) = getrlimit(Resource::RLIMIT_NOFILE).map_err(|e| ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to get file descriptor limit: {}", e),
))?;
setrlimit(Resource::RLIMIT_NOFILE, fds, hard).map_err(|e| ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to set file descriptor limit: {}", e),
))?;
}
if let Some(procs) = ulimit_opts.procs {
let (_, hard) = getrlimit(Resource::RLIMIT_NPROC).map_err(|e| ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to get process limit: {}", e),
))?;
setrlimit(Resource::RLIMIT_NPROC, procs, hard).map_err(|e| ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to set process limit: {}", e),
))?;
}
if let Some(stack) = ulimit_opts.stack {
let (_, hard) = getrlimit(Resource::RLIMIT_STACK).map_err(|e| ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to get stack size limit: {}", e),
))?;
setrlimit(Resource::RLIMIT_STACK, stack, hard).map_err(|e| ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to set stack size limit: {}", e),
))?;
}
if let Some(core) = ulimit_opts.core {
let (_, hard) = getrlimit(Resource::RLIMIT_CORE).map_err(|e| ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to get core dump size limit: {}", e),
))?;
setrlimit(Resource::RLIMIT_CORE, core, hard).map_err(|e| ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to set core dump size limit: {}", e),
))?;
}
if let Some(vmem) = ulimit_opts.vmem {
let (_, hard) = getrlimit(Resource::RLIMIT_AS).map_err(|e| ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to get virtual memory limit: {}", e),
))?;
setrlimit(Resource::RLIMIT_AS, vmem, hard).map_err(|e| ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to set virtual memory limit: {}", e),
))?;
}
state::set_status(0);
Ok(())
}

View File

@@ -30,7 +30,7 @@ use crate::builtin::trap::TrapTarget;
use crate::libsh::error::{self, ShErr, ShErrKind, ShResult};
use crate::libsh::sys::TTY_FILENO;
use crate::libsh::utils::AutoCmdVecUtils;
use crate::parse::execute::exec_input;
use crate::parse::execute::{exec_dash_c, exec_input};
use crate::prelude::*;
use crate::procio::borrow_fd;
use crate::readline::term::{LineWriter, RawModeGuard, raw_mode};
@@ -130,7 +130,7 @@ fn main() -> ExitCode {
if let Err(e) = if let Some(path) = args.script {
run_script(path, args.script_args)
} else if let Some(cmd) = args.command {
exec_input(cmd, None, false, None)
exec_dash_c(cmd)
} else {
let res = shed_interactive(args);
write(borrow_fd(*TTY_FILENO), b"\x1b[?2004l").ok(); // disable bracketed paste mode on exit

View File

@@ -5,31 +5,11 @@ use std::{
};
use ariadne::Fmt;
use nix::sys::resource;
use crate::{
builtin::{
alias::{alias, unalias},
arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate},
autocmd::autocmd,
cd::cd,
complete::{compgen_builtin, complete_builtin},
dirstack::{dirs, popd, pushd},
echo::echo,
eval, exec,
flowctl::flowctl,
getopts::getopts,
intro,
jobctl::{self, JobBehavior, continue_job, disown, jobs},
keymap, map,
pwd::pwd,
read::{self, read_builtin},
shift::shift,
shopt::shopt,
source::source,
test::double_bracket_test,
trap::{TrapTarget, trap},
varcmds::{export, local, readonly, unset},
zoltraak::zoltraak,
alias::{alias, unalias}, arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate}, autocmd::autocmd, cd::cd, complete::{compgen_builtin, complete_builtin}, dirstack::{dirs, popd, pushd}, echo::echo, eval, exec, flowctl::flowctl, getopts::getopts, intro, jobctl::{self, JobBehavior, continue_job, disown, jobs}, keymap, map, pwd::pwd, read::{self, read_builtin}, resource::ulimit, shift::shift, shopt::shopt, source::source, test::double_bracket_test, trap::{TrapTarget, trap}, varcmds::{export, local, readonly, unset}, zoltraak::zoltraak
},
expand::{expand_aliases, expand_case_pattern, glob_to_regex},
jobs::{ChildProc, JobStack, attach_tty, dispatch_job},
@@ -132,6 +112,64 @@ impl ExecArgs {
}
}
/// Execute a `-c` command string, optimizing single simple commands to exec
/// directly without forking. This avoids process group issues where grandchild
/// processes (e.g. nvim spawning opencode) lose their controlling terminal.
pub fn exec_dash_c(input: String) -> ShResult<()> {
let log_tab = read_logic(|l| l.clone());
let expanded = expand_aliases(input, HashSet::new(), &log_tab);
let source_name = "<shed -c>".to_string();
let mut parser = ParsedSrc::new(Arc::new(expanded))
.with_lex_flags(super::lex::LexFlags::empty())
.with_name(source_name.clone());
if let Err(errors) = parser.parse_src() {
for error in errors {
error.print_error();
}
return Ok(());
}
let mut nodes = parser.extract_nodes();
// Single simple command: exec directly without forking.
// The parser wraps single commands as Conjunction → Pipeline → Command.
// Unwrap all layers to check, then set NO_FORK on the inner Command.
if nodes.len() == 1 {
let is_single_cmd = match &nodes[0].class {
NdRule::Command { .. } => true,
NdRule::Pipeline { cmds } => cmds.len() == 1 && matches!(cmds[0].class, NdRule::Command { .. }),
NdRule::Conjunction { elements } => {
elements.len() == 1 && match &elements[0].cmd.class {
NdRule::Pipeline { cmds } => cmds.len() == 1 && matches!(cmds[0].class, NdRule::Command { .. }),
NdRule::Command { .. } => true,
_ => false,
}
}
_ => false,
};
if is_single_cmd {
// Unwrap to the inner Command node
let mut node = nodes.remove(0);
loop {
match node.class {
NdRule::Conjunction { mut elements } => { node = *elements.remove(0).cmd; }
NdRule::Pipeline { mut cmds } => { node = cmds.remove(0); }
NdRule::Command { .. } => break,
_ => break,
}
}
node.flags |= NdFlags::NO_FORK;
nodes.push(node);
}
}
let mut dispatcher = Dispatcher::new(nodes, false, source_name);
// exec_cmd expects a job on the stack (normally set up by exec_pipeline).
// For the NO_FORK exec-in-place path, create one so it doesn't panic.
dispatcher.job_stack.new_job();
dispatcher.begin_dispatch()
}
pub fn exec_input(
input: String,
io_stack: Option<IoStack>,
@@ -909,6 +947,7 @@ impl Dispatcher {
"keymap" => keymap::keymap(cmd),
"read_key" => read::read_key(cmd),
"autocmd" => autocmd(cmd),
"ulimit" => ulimit(cmd),
"true" | ":" => {
state::set_status(0);
Ok(())
@@ -946,7 +985,6 @@ impl Dispatcher {
}
let no_fork = cmd.flags.contains(NdFlags::NO_FORK);
if argv.is_empty() {
return Ok(());
}
@@ -959,36 +997,34 @@ impl Dispatcher {
let existing_pgid = job.pgid();
let fg_job = self.fg_job;
let interactive = self.interactive;
let child_logic = |pgid: Option<Pid>| -> ! {
// Put ourselves in the correct process group before exec.
// For the first child in a pipeline pgid is None, so we
// become our own group leader (setpgid(0,0)). For later
// children we join the leader's group.
let our_pgid = pgid.unwrap_or(Pid::from_raw(0));
let _ = setpgid(Pid::from_raw(0), our_pgid);
// For non-interactive exec-in-place (e.g. shed -c), skip process group
// and terminal setup — just transparently replace the current process.
if interactive || !no_fork {
// Put ourselves in the correct process group before exec.
// For the first child in a pipeline pgid is None, so we
// become our own group leader (setpgid(0,0)). For later
// children we join the leader's group.
let our_pgid = pgid.unwrap_or(Pid::from_raw(0));
let _ = setpgid(Pid::from_raw(0), our_pgid);
// For foreground jobs, take the terminal BEFORE resetting
// signals. SIGTTOU is still SIG_IGN (inherited from the shell),
// so tcsetpgrp won't stop us. This prevents a race
// where the child exec's and tries to read stdin before the
// parent has called tcsetpgrp — which would deliver SIGTTIN
// (now SIG_DFL after reset_signals) and stop the child.
if fg_job {
let tty_pgid = if our_pgid == Pid::from_raw(0) {
nix::unistd::getpid()
} else {
our_pgid
};
let _ = tcsetpgrp(
unsafe { BorrowedFd::borrow_raw(*crate::libsh::sys::TTY_FILENO) },
tty_pgid,
);
if fg_job {
let tty_pgid = if our_pgid == Pid::from_raw(0) {
nix::unistd::getpid()
} else {
our_pgid
};
let _ = tcsetpgrp(
unsafe { BorrowedFd::borrow_raw(*crate::libsh::sys::TTY_FILENO) },
tty_pgid,
);
}
}
// Reset signal dispositions before exec. SIG_IGN is preserved
// across execvpe, so the shell's ignored SIGTTIN/SIGTTOU would
// leak into child processes.
crate::signal::reset_signals();
if interactive || !no_fork {
crate::signal::reset_signals(fg_job);
}
let cmd = &exec_args.cmd.0;
let span = exec_args.cmd.1;

View File

@@ -154,10 +154,10 @@ pub fn sig_setup(is_login: bool) {
}
}
/// Reset all signal dispositions to SIG_DFL.
/// Reset signal dispositions to SIG_DFL.
/// Called in child processes before exec so that the shell's custom
/// handlers and SIG_IGN dispositions don't leak into child programs.
pub fn reset_signals() {
pub fn reset_signals(is_fg: bool) {
let default = SigAction::new(SigHandler::SigDfl, SaFlags::empty(), SigSet::empty());
unsafe {
for sig in Signal::iterator() {
@@ -165,6 +165,10 @@ pub fn reset_signals() {
if sig == Signal::SIGKILL || sig == Signal::SIGSTOP {
continue;
}
if is_fg && (sig == Signal::SIGTTIN || sig == Signal::SIGTTOU) {
log::debug!("Not resetting SIGTTIN/SIGTTOU in foreground child");
continue;
}
let _ = sigaction(sig, &default);
}
}