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

@@ -17,7 +17,20 @@ env_logger = "0.11.9"
glob = "0.3.2" glob = "0.3.2"
itertools = "0.14.0" itertools = "0.14.0"
log = "0.4.29" log = "0.4.29"
nix = { version = "0.29.0", features = ["uio", "term", "user", "hostname", "fs", "default", "signal", "process", "event", "ioctl", "poll"] } nix = { version = "0.29.0", features = [
"uio",
"term",
"user",
"resource",
"hostname",
"fs",
"default",
"signal",
"process",
"event",
"ioctl",
"poll"
] }
rand = "0.10.0" rand = "0.10.0"
regex = "1.11.1" regex = "1.11.1"
scopeguard = "1.2.0" scopeguard = "1.2.0"

View File

@@ -24,13 +24,14 @@ pub mod test; // [[ ]] thing
pub mod trap; pub mod trap;
pub mod varcmds; pub mod varcmds;
pub mod zoltraak; 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", "echo", "cd", "read", "export", "local", "pwd", "source", "shift", "jobs", "fg", "bg", "disown",
"alias", "unalias", "return", "break", "continue", "exit", "zoltraak", "shopt", "builtin", "alias", "unalias", "return", "break", "continue", "exit", "zoltraak", "shopt", "builtin",
"command", "trap", "pushd", "popd", "dirs", "exec", "eval", "true", "false", ":", "readonly", "command", "trap", "pushd", "popd", "dirs", "exec", "eval", "true", "false", ":", "readonly",
"unset", "complete", "compgen", "map", "pop", "fpop", "push", "fpush", "rotate", "wait", "type", "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<()> { 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::error::{self, ShErr, ShErrKind, ShResult};
use crate::libsh::sys::TTY_FILENO; use crate::libsh::sys::TTY_FILENO;
use crate::libsh::utils::AutoCmdVecUtils; 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::prelude::*;
use crate::procio::borrow_fd; use crate::procio::borrow_fd;
use crate::readline::term::{LineWriter, RawModeGuard, raw_mode}; 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 { if let Err(e) = if let Some(path) = args.script {
run_script(path, args.script_args) run_script(path, args.script_args)
} else if let Some(cmd) = args.command { } else if let Some(cmd) = args.command {
exec_input(cmd, None, false, None) exec_dash_c(cmd)
} else { } else {
let res = shed_interactive(args); let res = shed_interactive(args);
write(borrow_fd(*TTY_FILENO), b"\x1b[?2004l").ok(); // disable bracketed paste mode on exit 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 ariadne::Fmt;
use nix::sys::resource;
use crate::{ use crate::{
builtin::{ builtin::{
alias::{alias, unalias}, 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
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,
}, },
expand::{expand_aliases, expand_case_pattern, glob_to_regex}, expand::{expand_aliases, expand_case_pattern, glob_to_regex},
jobs::{ChildProc, JobStack, attach_tty, dispatch_job}, 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( pub fn exec_input(
input: String, input: String,
io_stack: Option<IoStack>, io_stack: Option<IoStack>,
@@ -909,6 +947,7 @@ impl Dispatcher {
"keymap" => keymap::keymap(cmd), "keymap" => keymap::keymap(cmd),
"read_key" => read::read_key(cmd), "read_key" => read::read_key(cmd),
"autocmd" => autocmd(cmd), "autocmd" => autocmd(cmd),
"ulimit" => ulimit(cmd),
"true" | ":" => { "true" | ":" => {
state::set_status(0); state::set_status(0);
Ok(()) Ok(())
@@ -946,7 +985,6 @@ impl Dispatcher {
} }
let no_fork = cmd.flags.contains(NdFlags::NO_FORK); let no_fork = cmd.flags.contains(NdFlags::NO_FORK);
if argv.is_empty() { if argv.is_empty() {
return Ok(()); return Ok(());
} }
@@ -959,36 +997,34 @@ impl Dispatcher {
let existing_pgid = job.pgid(); let existing_pgid = job.pgid();
let fg_job = self.fg_job; let fg_job = self.fg_job;
let interactive = self.interactive;
let child_logic = |pgid: Option<Pid>| -> ! { let child_logic = |pgid: Option<Pid>| -> ! {
// Put ourselves in the correct process group before exec. // For non-interactive exec-in-place (e.g. shed -c), skip process group
// For the first child in a pipeline pgid is None, so we // and terminal setup — just transparently replace the current process.
// become our own group leader (setpgid(0,0)). For later if interactive || !no_fork {
// children we join the leader's group. // Put ourselves in the correct process group before exec.
let our_pgid = pgid.unwrap_or(Pid::from_raw(0)); // For the first child in a pipeline pgid is None, so we
let _ = setpgid(Pid::from_raw(0), our_pgid); // 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 if fg_job {
// signals. SIGTTOU is still SIG_IGN (inherited from the shell), let tty_pgid = if our_pgid == Pid::from_raw(0) {
// so tcsetpgrp won't stop us. This prevents a race nix::unistd::getpid()
// where the child exec's and tries to read stdin before the } else {
// parent has called tcsetpgrp — which would deliver SIGTTIN our_pgid
// (now SIG_DFL after reset_signals) and stop the child. };
if fg_job { let _ = tcsetpgrp(
let tty_pgid = if our_pgid == Pid::from_raw(0) { unsafe { BorrowedFd::borrow_raw(*crate::libsh::sys::TTY_FILENO) },
nix::unistd::getpid() tty_pgid,
} 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 if interactive || !no_fork {
// across execvpe, so the shell's ignored SIGTTIN/SIGTTOU would crate::signal::reset_signals(fg_job);
// leak into child processes. }
crate::signal::reset_signals();
let cmd = &exec_args.cmd.0; let cmd = &exec_args.cmd.0;
let span = exec_args.cmd.1; 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 /// Called in child processes before exec so that the shell's custom
/// handlers and SIG_IGN dispositions don't leak into child programs. /// 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()); let default = SigAction::new(SigHandler::SigDfl, SaFlags::empty(), SigSet::empty());
unsafe { unsafe {
for sig in Signal::iterator() { for sig in Signal::iterator() {
@@ -165,6 +165,10 @@ pub fn reset_signals() {
if sig == Signal::SIGKILL || sig == Signal::SIGSTOP { if sig == Signal::SIGKILL || sig == Signal::SIGSTOP {
continue; continue;
} }
if is_fg && (sig == Signal::SIGTTIN || sig == Signal::SIGTTOU) {
log::debug!("Not resetting SIGTTIN/SIGTTOU in foreground child");
continue;
}
let _ = sigaction(sig, &default); let _ = sigaction(sig, &default);
} }
} }