feat(runtime): add basic runtime

This commit is contained in:
buffet 2022-09-15 20:16:37 +00:00
parent f2e4f43f35
commit 0bfa8b3c8f
13 changed files with 496 additions and 8 deletions

49
Cargo.lock generated
View file

@ -8,6 +8,18 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]] [[package]]
name = "console" name = "console"
version = "0.15.0" version = "0.15.0"
@ -29,9 +41,9 @@ checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.2" version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "607c8a29735385251a339424dd462993c0fed8fa09d378f259377df08c126022" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
@ -65,9 +77,9 @@ checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.126" version = "0.2.132"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5"
[[package]] [[package]]
name = "linked-hash-map" name = "linked-hash-map"
@ -76,10 +88,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]] [[package]]
name = "once_cell" name = "nix"
version = "1.13.0" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1" checksum = "e322c04a9e3440c327fca7b6c8a63e6890a32fa2ad689db972425f07e0d22abb"
dependencies = [
"autocfg",
"bitflags",
"cfg-if",
"libc",
]
[[package]]
name = "once_cell"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f7254b99e31cad77da24b08ebf628882739a608578bb1bcdfc1f9c21260d7c0"
[[package]] [[package]]
name = "oyster" name = "oyster"
@ -87,6 +111,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"oyster_lineedit", "oyster_lineedit",
"oyster_parser", "oyster_parser",
"oyster_runtime",
] ]
[[package]] [[package]]
@ -101,6 +126,16 @@ dependencies = [
"thiserror", "thiserror",
] ]
[[package]]
name = "oyster_runtime"
version = "0.0.0"
dependencies = [
"insta",
"nix",
"oyster_parser",
"thiserror",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.40" version = "1.0.40"

View file

@ -6,3 +6,4 @@ edition = "2021"
[dependencies] [dependencies]
oyster_lineedit = { path = "../oyster_lineedit" } oyster_lineedit = { path = "../oyster_lineedit" }
oyster_parser = { path = "../oyster_parser" } oyster_parser = { path = "../oyster_parser" }
oyster_runtime = { path = "../oyster_runtime" }

View file

@ -1,7 +1,13 @@
use std::process;
use oyster_lineedit::readline; use oyster_lineedit::readline;
use oyster_parser::ast::Code; use oyster_parser::ast::Code;
use oyster_runtime::{Shell, Status};
fn main() { fn main() {
let mut shell = Shell::default();
let mut exit_code = Status::SUCCESS;
loop { loop {
let prog = readline("> ").unwrap(); let prog = readline("> ").unwrap();
@ -9,6 +15,9 @@ fn main() {
break; break;
} }
Code::try_from(prog.as_ref()).unwrap(); let ast = Code::try_from(prog.as_ref()).unwrap();
exit_code = shell.run(&ast).unwrap();
} }
process::exit(exit_code.0);
} }

View file

@ -0,0 +1,19 @@
[package]
name = "oyster_runtime"
version = "0.0.0"
edition = "2021"
[lib]
doctest = false
[dependencies]
oyster_parser = { path = "../oyster_parser" }
thiserror = "1.0.35"
[dependencies.nix]
version = "~0.25.0"
default-features = false
features = [ "fs", "ioctl", "process", "term" ]
[dev-dependencies]
insta = "^1.15.0"

View file

@ -0,0 +1,53 @@
//! The runtime for executing oyster programs.
//! Panics when an invalid ast gets passed.
mod pipeline;
use std::io;
use oyster_parser::ast;
use thiserror::Error;
/// Exit status of a finished command.
#[derive(Debug)]
pub struct Status(pub i32);
impl Status {
pub const SUCCESS: Status = Status(0);
pub const COULD_NOT_EXEC: Status = Status(126);
pub const COMMAND_NOT_FOUND: Status = Status(127);
pub const SIG_BASE: Status = Status(128);
}
/// Errors that occur during runtime.
#[derive(Debug, Error)]
pub enum RuntimeError {
#[error("failed to create pipe: {0}")]
PipeCreationFailed(#[source] nix::Error),
#[error("command not found: {0}")]
CommandNotFound(String),
#[error("permission denied: {0}")]
PermissionDenied(String),
#[error("failed to spawn process: {0}")]
SpawnFailed(#[source] io::Error),
}
#[derive(Default)]
pub struct Shell;
impl Shell {
pub fn run<'a>(&mut self, code: &'a ast::Code) -> Result<Status, RuntimeError> {
let mut last_status = Status::SUCCESS;
for stmt in code.0.iter() {
last_status = match stmt {
ast::Statement::Pipeline(pipeline) => self.run_pipeline(pipeline)?,
};
}
Ok(last_status)
}
}

View file

@ -0,0 +1,165 @@
use std::{
borrow::Cow,
ffi::{OsStr, OsString},
fs::File,
io,
os::unix::{
io::FromRawFd,
process::{CommandExt, ExitStatusExt},
},
process::{Child, Command, Stdio},
slice::Iter,
};
use nix::{
fcntl::OFlag,
unistd::{self, Pid},
};
use oyster_parser::ast::{self, Redirect};
use crate::{RuntimeError, Shell, Status};
/// A command that's ready to be run.
struct PreparedCommand<'a> {
cmd: Cow<'a, str>,
args: WordBuilder<'a>,
redirect: Redirect,
stdin: Option<File>,
stdout: Option<File>,
stderr: Option<File>,
}
impl<'a> PreparedCommand<'a> {
/// Run this command with the given context.
fn spawn(self, pgid: &mut Pid) -> Result<Child, RuntimeError> {
let args = self.args.map(|w| match w {
Cow::Borrowed(s) => Cow::Borrowed(OsStr::new(s)),
Cow::Owned(s) => Cow::Owned(OsString::from(s)),
});
let mut cmd = Command::new(self.cmd.as_ref());
cmd.args(args);
cmd.stdin(self.stdin.map_or(Stdio::inherit(), Stdio::from));
cmd.stdout(self.stdout.map_or(Stdio::inherit(), Stdio::from));
cmd.stderr(self.stderr.map_or(Stdio::inherit(), Stdio::from));
cmd.process_group(pgid.as_raw());
let child = match cmd.spawn() {
Ok(child) => child,
Err(err) => {
return Err(match err.kind() {
io::ErrorKind::NotFound => RuntimeError::CommandNotFound(self.cmd.into_owned()),
io::ErrorKind::PermissionDenied => {
RuntimeError::PermissionDenied(self.cmd.into_owned())
}
_ => RuntimeError::SpawnFailed(err),
})
}
};
let child_id = Pid::from_raw(child.id() as i32);
if *pgid == Pid::from_raw(0) {
*pgid = child_id;
};
// prevent race conditions
let _ = unistd::setpgid(child_id, *pgid);
Ok(child)
}
}
impl<'a> From<&'a ast::Command<'a>> for PreparedCommand<'a> {
fn from(command: &'a ast::Command<'a>) -> Self {
let mut words = WordBuilder(command.0.iter());
let cmd = words.next().expect("words need to have >1 parts");
let args = words;
let redirect = command.1;
PreparedCommand {
cmd,
args,
redirect,
stdin: None,
stdout: None,
stderr: None,
}
}
}
impl Shell {
pub(crate) fn run_pipeline(
&mut self,
pipeline: &ast::Pipeline,
) -> Result<Status, RuntimeError> {
let mut children = Vec::with_capacity(pipeline.0.len());
let mut cmds = pipeline.0.iter().map(PreparedCommand::from);
let mut pgid = Pid::from_raw(0);
let mut last_cmd = cmds.next().expect("pipelines need to have >1 commands");
for mut cmd in cmds {
let (output, input) = create_pipe()?;
cmd.stdin = Some(output);
match last_cmd.redirect {
Redirect::None => (),
Redirect::Stdout => last_cmd.stdout = Some(input),
}
children.push(last_cmd.spawn(&mut pgid)?);
last_cmd = cmd;
}
children.push(last_cmd.spawn(&mut pgid)?);
// TODO: kill children if error occured
// TODO: set foreground group then wait for foreground group
let mut last_status = Status::SUCCESS;
for mut c in children {
// TODO: handle error
let status = c.wait().unwrap();
last_status = Status(
status
.code()
.unwrap_or_else(|| Status::SIG_BASE.0 + status.signal().unwrap()),
);
}
Ok(last_status)
}
}
/// Create a new unix pipe.
fn create_pipe() -> Result<(File, File), RuntimeError> {
let (output, input) =
unistd::pipe2(OFlag::O_CLOEXEC).map_err(RuntimeError::PipeCreationFailed)?;
Ok(unsafe { (File::from_raw_fd(output), File::from_raw_fd(input)) })
}
/// Build words from WordParts.
struct WordBuilder<'a>(Iter<'a, ast::Word<'a>>);
impl<'a> Iterator for WordBuilder<'a> {
type Item = Cow<'a, str>;
fn next(&mut self) -> Option<Self::Item> {
self.0.next().map(|word| {
let mut words = word.0.iter();
let mut s = match words.next().expect("words need to have >1 parts") {
ast::WordPart::Text(text) => Cow::from(*text),
};
for part in words {
match part {
ast::WordPart::Text(text) => s.to_mut().push_str(text),
}
}
s
})
}
}

View file

@ -0,0 +1 @@
mod pipeline;

View file

@ -0,0 +1,172 @@
use std::{
env,
fs::File,
io::{BufRead, BufReader, Write},
os::unix::io::FromRawFd,
process,
};
use insta::assert_debug_snapshot as assert_snapshot;
use nix::{
ioctl_write_int_bad, libc,
pty::{self, OpenptyResult},
sys,
unistd::{self, ForkResult},
};
use oyster_runtime::Shell;
// TODO: test signal return codes
ioctl_write_int_bad!(tiocsctty, libc::TIOCSCTTY);
/// Forks to redirect stdin, stderr, stdout, then run the commands.
/// Relies on inserting a NUL byte in the end, so there shouldn't be NUL in the output.
fn collect_output<F>(mut f: F) -> String
where
F: FnMut(),
{
let OpenptyResult { master, slave } = pty::openpty(None, None).unwrap();
match unsafe { unistd::fork() }.unwrap() {
ForkResult::Parent { child } => {
sys::wait::waitpid(child, None).unwrap();
}
ForkResult::Child => {
unistd::setsid().unwrap();
unsafe { tiocsctty(slave, 0) }.unwrap();
unistd::dup2(slave, libc::STDIN_FILENO).unwrap();
unistd::dup2(slave, libc::STDOUT_FILENO).unwrap();
unistd::dup2(slave, libc::STDERR_FILENO).unwrap();
let _ = unistd::close(master);
let _ = unistd::close(slave);
f();
process::exit(0);
}
}
let master = unsafe { File::from_raw_fd(master) };
let mut slave = unsafe { File::from_raw_fd(slave) };
slave.write(&[0]).unwrap();
let mut r = BufReader::new(master);
let mut buf = vec![];
r.read_until(0, &mut buf).unwrap();
std::str::from_utf8(&buf[..buf.len() - 1])
.unwrap()
.to_owned()
}
#[test]
fn simple_command() {
let ast = {
use oyster_parser::ast::*;
Code(vec![Statement::Pipeline(Pipeline(vec![Command(
vec![
Word(vec![WordPart::Text("echo")]),
Word(vec![WordPart::Text("hi")]),
],
Redirect::None,
)]))])
};
let actual = collect_output(|| {
let mut shell = Shell::default();
shell.run(&ast).unwrap();
});
assert_snapshot!(actual);
}
#[test]
fn pipeline() {
let ast = {
use oyster_parser::ast::*;
Code(vec![Statement::Pipeline(Pipeline(vec![
Command(
vec![
Word(vec![WordPart::Text("echo")]),
Word(vec![WordPart::Text("hi")]),
],
Redirect::Stdout,
),
Command(
vec![
Word(vec![WordPart::Text("wc")]),
Word(vec![WordPart::Text("-c")]),
],
Redirect::None,
),
]))])
};
let actual = collect_output(|| {
let mut shell = Shell::default();
shell.run(&ast).unwrap();
});
assert_snapshot!(actual);
}
#[test]
fn command_not_found() {
let ast = {
use oyster_parser::ast::*;
Code(vec![Statement::Pipeline(Pipeline(vec![Command(
vec![Word(vec![WordPart::Text("this_command_doesnt_exist")])],
Redirect::None,
)]))])
};
// XXX: this relies on the command actually not existing, as unsetting PATH is rather complex
let mut shell = Shell::default();
let actual = shell.run(&ast);
assert_snapshot!(actual);
}
#[test]
fn permission_denied() {
let ast = {
use oyster_parser::ast::*;
Code(vec![Statement::Pipeline(Pipeline(vec![Command(
vec![Word(vec![WordPart::Text("/")])],
Redirect::None,
)]))])
};
let mut shell = Shell::default();
let actual = shell.run(&ast);
assert_snapshot!(actual);
}
#[test]
fn multipart_word() {
let ast = {
use oyster_parser::ast::*;
Code(vec![Statement::Pipeline(Pipeline(vec![Command(
vec![
Word(vec![WordPart::Text("ec"), WordPart::Text("ho")]),
Word(vec![WordPart::Text("hel"), WordPart::Text("lo")]),
],
Redirect::None,
)]))])
};
let actual = collect_output(|| {
let mut shell = Shell::default();
shell.run(&ast).unwrap();
});
assert_snapshot!(actual);
}

View file

@ -0,0 +1,9 @@
---
source: crates/oyster_runtime/tests/it/pipeline.rs
expression: actual
---
Err(
CommandNotFound(
"this_command_doesnt_exist",
),
)

View file

@ -0,0 +1,5 @@
---
source: crates/oyster_runtime/tests/it/pipeline.rs
expression: actual
---
"hello\r\n"

View file

@ -0,0 +1,9 @@
---
source: crates/oyster_runtime/tests/it/pipeline.rs
expression: actual
---
Err(
PermissionDenied(
"/",
),
)

View file

@ -0,0 +1,5 @@
---
source: crates/oyster_runtime/tests/it/pipeline.rs
expression: actual
---
"3\r\n"

View file

@ -0,0 +1,5 @@
---
source: crates/oyster_runtime/tests/it/pipeline.rs
expression: actual
---
"hi\r\n"