Skip to content

Commit

Permalink
feat(cli): added hot reloading
Browse files Browse the repository at this point in the history
Ended up making the CLI spawn another version of itself and then the
original acts as a watcher. This introduces a privilege escalation
vulnerability though if installed as root (happened with PulseAudio
several years ago).
  • Loading branch information
arctic-hen7 committed Jan 2, 2022
1 parent 998a041 commit 61696b3
Show file tree
Hide file tree
Showing 3 changed files with 133 additions and 133 deletions.
4 changes: 3 additions & 1 deletion packages/perseus-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ clap = { version = "=3.0.0-beta.5", features = ["color"] }
fs_extra = "1"
tokio = { version = "1", features = [ "macros", "rt-multi-thread", "sync" ] }
warp = "0.3"
notify = "4"
command-group = "1"
ctrlc = { version = "3.0", features = ["termination"] }
notify = "=5.0.0-pre.13"

[lib]
name = "perseus_cli"
Expand Down
252 changes: 120 additions & 132 deletions packages/perseus-cli/src/bin/main.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use clap::Parser;
use command_group::stdlib::CommandGroup;
use fmterr::fmt_err;
use notify::{watcher, RecursiveMode, Watcher};
use perseus_cli::parse::SnoopSubcommand;
use notify::{recommended_watcher, RecursiveMode, Watcher};
use perseus_cli::parse::{ExportOpts, ServeOpts, SnoopSubcommand};
use perseus_cli::{
build, check_env, delete_artifacts, delete_bad_dir, deploy, eject, export, has_ejected,
parse::{Opts, Subcommand},
Expand All @@ -11,8 +12,8 @@ use perseus_cli::{errors::*, snoop_build, snoop_server, snoop_wasm_build};
use std::env;
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;
use std::sync::mpsc::channel;
use std::time::Duration;

// All this does is run the program and terminate with the acquired exit code
#[tokio::main]
Expand Down Expand Up @@ -62,6 +63,14 @@ async fn real_main() -> i32 {
}
}

// This is used internally for message passing
enum Event {
// Sent if we should restart the child process
Reload,
// Sent if we should temrinate the child process
Terminate,
}

// This performs the actual logic, separated for deduplication of error handling and destructor control
// This returns the exit code of the executed command, which we should return from the process itself
// This prints warnings using the `writeln!` macro, which allows the parsing of `stdout` in production or a vector in testing
Expand All @@ -80,6 +89,109 @@ async fn core(dir: PathBuf) -> Result<i32, Error> {
// Check the user's environment to make sure they have prerequisites
// We do this after any help pages or version numbers have been parsed for snappiness
check_env()?;

// Check if this process is allowed to watch for changes
// This will be set to `true` if this is a child process
// The CLI will actually spawn another version of itself if we're watching for changes
// The reason for this is to avoid having to manage handlers for multiple threads and other child processes
// After several days of attempting, this is the only feasible solution (short of a full rewrite of the CLI)
let watch_allowed = env::var("PERSEUS_WATCHING_PROHIBITED").is_err();
// Check if the user wants to watch for changes
match &opts.subcmd {
Subcommand::Export(ExportOpts { watch, .. })
| Subcommand::Serve(ServeOpts { watch, .. })
if *watch && watch_allowed =>
{
let (tx_term, rx) = channel();
let tx_fs = tx_term.clone();
// Set the handler for termination events (more than just SIGINT) on all platforms
// We do this before anything else so that, if it fails, we don't have servers left open
ctrlc::set_handler(move || {
tx_term
.send(Event::Terminate)
.expect("couldn't shut down child processes (servers may have been left open)")
})
.expect("couldn't set handlers to gracefully terminate process");

// Find out where this binary is
// SECURITY: If the CLI were installed with root privileges, it would be possible to create a hard link to the
// binary, execute through that, and then replace it with a malicious binary before we got here which would
// allow privilege escalation. See https://vulners.com/securityvulns/SECURITYVULNS:DOC:22183.
// TODO Drop root privileges at startup
let bin_name =
env::current_exe().map_err(|err| WatchError::GetSelfPathFailed { source: err })?;
// Get the arguments to provide
// These are the same, but we'll disallow watching with an environment variable
let mut args = env::args().collect::<Vec<String>>();
// We'll remove the first element of the arguments (binary name, but less reliable)
args.remove(0);

// Set up a watcher
let mut watcher = recommended_watcher(move |_| {
// If this fails, the watcher channel was completely disconnected, which should never happen (it's in a loop)
tx_fs.send(Event::Reload).unwrap();
})
.map_err(|err| WatchError::WatcherSetupFailed { source: err })?;
// Watch the current directory
for entry in std::fs::read_dir(".")
.map_err(|err| WatchError::ReadCurrentDirFailed { source: err })?
{
// We want to exclude `target/` and `.perseus/`, otherwise we should watch everything
let entry = entry.map_err(|err| WatchError::ReadDirEntryFailed { source: err })?;
let name = entry.file_name();
if name != "target" && name != ".perseus" {
watcher
.watch(&entry.path(), RecursiveMode::Recursive)
.map_err(|err| WatchError::WatchFileFailed {
filename: entry.path().to_str().unwrap().to_string(),
source: err,
})?;
}
}

// This will store the handle to the child process
// This will be updated every time we re-create the process
// We spawn it as a process group, whcih means signals go to grandchild processes as well, which means hot reloading
// can actually work!
let mut child = Command::new(&bin_name)
.args(&args)
.env("PERSEUS_WATCHING_PROHIBITED", "true")
.group_spawn()
.map_err(|err| WatchError::SpawnSelfFailed { source: err })?;

let res = loop {
match rx.recv() {
Ok(Event::Reload) => {
// Kill the current child process
// This will return an error if the child has already exited, which is fine
// This gracefully kills the process in the sense that it kills it and all its children
let _ = child.kill();
// Restart it
child = Command::new(&bin_name)
.args(&args)
.env("PERSEUS_WATCHING_PROHIBITED", "true")
.group_spawn()
.map_err(|err| WatchError::SpawnSelfFailed { source: err })?;
}
Ok(Event::Terminate) => {
// This means the user is trying to stop the process
// We have to manually terminate the process group, because it's a process *group*
let _ = child.kill();
// From here, we can let the prgoram terminate naturally
break Ok(0);
}
Err(err) => break Err(WatchError::WatcherError { source: err }),
}
};
let exit_code = res?;
Ok(exit_code)
}
// If not, just run the central logic normally
_ => core_watch(dir, opts).await,
}
}

async fn core_watch(dir: PathBuf, opts: Opts) -> Result<i32, Error> {
// If we're not cleaning up artifacts, create them if needed
if !matches!(opts.subcmd, Subcommand::Clean(_)) {
prepare(dir.clone())?;
Expand All @@ -98,141 +210,17 @@ async fn core(dir: PathBuf) -> Result<i32, Error> {
if exit_code != 0 {
return Ok(exit_code);
}

if export_opts.watch {
let dir_2 = dir.clone();
let export_opts_2 = export_opts.clone();
if export_opts.serve {
tokio::spawn(async move {
serve_exported(dir_2, export_opts_2.host, export_opts_2.port).await
});
}
// Now watch for changes
let (tx, rx) = channel();
let mut watcher = watcher(tx, Duration::from_secs(2))
.map_err(|err| WatchError::WatcherSetupFailed { source: err })?;
// Watch the current directory
for entry in std::fs::read_dir(".")
.map_err(|err| WatchError::ReadCurrentDirFailed { source: err })?
{
// We want to exclude `target/` and `.perseus/`, otherwise we should watch everything
let entry =
entry.map_err(|err| WatchError::ReadDirEntryFailed { source: err })?;
let name = entry.file_name();
if name != "target" && name != ".perseus" {
watcher
.watch(entry.path(), RecursiveMode::Recursive)
.map_err(|err| WatchError::WatchFileFailed {
filename: entry.path().to_str().unwrap().to_string(),
source: err,
})?;
}
}

let res: Result<i32, Error> = loop {
match rx.recv() {
Ok(_) => {
// Delete old build/exportation artifacts
delete_artifacts(dir.clone(), "static")?;
delete_artifacts(dir.clone(), "exported")?;
let dir_2 = dir.clone();
let opts = export_opts.clone();
match export(dir_2.clone(), opts.clone()) {
// We'l let the user know if there's a non-zero exit code
Ok(exit_code) => {
if exit_code != 0 {
eprintln!("Non-zero exit code returned from exporting process: {}.", exit_code)
}
}
// Because we're watching for changes, we can manage errors here
// We won't actually terminate unless the user tells us to
Err(err) => eprintln!("{}", fmt_err(&err)),
}
// TODO Reload the browser automatically
}
Err(err) => break Err(WatchError::WatcherError { source: err }.into()),
}
};
return res;
} else {
if export_opts.serve {
serve_exported(dir, export_opts.host, export_opts.port).await;
}
0
if export_opts.serve {
serve_exported(dir, export_opts.host, export_opts.port).await;
}
0
}
Subcommand::Serve(serve_opts) => {
if !serve_opts.no_build {
delete_artifacts(dir.clone(), "static")?;
}
if serve_opts.watch {
match serve(dir.clone(), serve_opts.clone()) {
// We'll let the user know if there's a non-zero exit code
Ok((exit_code, _server_path)) => {
if exit_code != 0 {
eprintln!(
"Non-zero exit code returned from serving process: {}.",
exit_code
)
}
}
// Because we're watching for changes, we can manage errors here
// We won't actually terminate unless the user tells us to
Err(err) => eprintln!("{}", fmt_err(&err)),
};
// Now watch for changes
let (tx, rx) = channel();
let mut watcher = watcher(tx, Duration::from_secs(2))
.map_err(|err| WatchError::WatcherSetupFailed { source: err })?;
// Watch the current directory
for entry in std::fs::read_dir(".")
.map_err(|err| WatchError::ReadCurrentDirFailed { source: err })?
{
// We want to exclude `target/` and `.perseus/`, otherwise we should watch everything
let entry =
entry.map_err(|err| WatchError::ReadDirEntryFailed { source: err })?;
let name = entry.file_name();
if name != "target" && name != ".perseus" {
watcher
.watch(entry.path(), RecursiveMode::Recursive)
.map_err(|err| WatchError::WatchFileFailed {
filename: entry.path().to_str().unwrap().to_string(),
source: err,
})?;
}
}

let res: Result<i32, Error> = loop {
match rx.recv() {
Ok(_) => {
// Delete old build artifacts if `--no-build` wasn't specified
if !serve_opts.no_build {
delete_artifacts(dir.clone(), "static")?;
}
match serve(dir.clone(), serve_opts.clone()) {
// We'll let the user know if there's a non-zero exit code
Ok((exit_code, _server_path)) => {
if exit_code != 0 {
eprintln!(
"Non-zero exit code returned from serving process: {}.",
exit_code
)
}
}
// Because we're watching for changes, we can manage errors here
// We won't actually terminate unless the user tells us to
Err(err) => eprintln!("{}", fmt_err(&err)),
};
// TODO Reload the browser automatically
}
Err(err) => break Err(WatchError::WatcherError { source: err }.into()),
}
};
return res;
} else {
let (exit_code, _server_path) = serve(dir, serve_opts)?;
exit_code
}
let (exit_code, _server_path) = serve(dir, serve_opts)?;
exit_code
}
Subcommand::Test(test_opts) => {
// This will be used by the subcrates
Expand Down
10 changes: 10 additions & 0 deletions packages/perseus-cli/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,4 +228,14 @@ pub enum WatchError {
#[source]
source: std::sync::mpsc::RecvError,
},
#[error("couldn't spawn a child process to build your app in watcher mode")]
SpawnSelfFailed {
#[source]
source: std::io::Error,
},
#[error("couldn't get the path to the cli's executable, try re-running the command")]
GetSelfPathFailed {
#[source]
source: std::io::Error,
},
}

0 comments on commit 61696b3

Please sign in to comment.