Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MVP debugger #123

Merged
merged 13 commits into from
Dec 17, 2024
8 changes: 7 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,19 @@ paste = "1.0.15"
ctor = "0.2.8"
once_cell = "1.20.2"
clap = { version = "4.5.21", features = ["derive"] }
assert_cmd = "2.0.16"
shlex = "1.3.0"

[dev-dependencies]
rand_distr = "0.4.3"
tempfile = "3.3"
ordered-float = "4.3.0"
predicates = "3.1.2"
assert_cmd = "2.0.16"

[[bin]]
name = "runner_test_custom_args"
path = "tests/bin/runner_test_custom_args.rs"

[[bin]]
name = "runner_test_debug"
path = "tests/bin/runner_test_debug.rs"
218 changes: 218 additions & 0 deletions src/debugger.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
use crate::Context;
use crate::ContextPeopleExt;
use crate::IxaError;
use clap::{ArgMatches, Command};
use std::cell::RefCell;
use std::collections::HashMap;
use std::io::Write;

trait DebuggerCommand {
/// Handle the command and any inputs; returning true will exit the debugger
fn handle(
&self,
context: &mut Context,
cli: &DebuggerRepl,
matches: &ArgMatches,
) -> Result<bool, String>;
fn about(&self) -> &'static str;
}

struct DebuggerRepl {
commands: HashMap<&'static str, Box<dyn DebuggerCommand>>,
output: RefCell<Box<dyn Write>>,
}

fn flush() -> Result<(), String> {
std::io::stdout().flush().map_err(|e| e.to_string())
}

impl DebuggerRepl {
fn new() -> Self {
DebuggerRepl {
commands: HashMap::new(),
output: RefCell::new(Box::new(std::io::stdout())),
}
}

fn register_command(&mut self, name: &'static str, handler: Box<dyn DebuggerCommand>) {
self.commands.insert(name, handler);
}

fn get_command(&self, name: &str) -> Option<&dyn DebuggerCommand> {
self.commands.get(name).map(|command| &**command)
}

fn writeln(&self, formatted_string: &str) -> Result<(), String> {
k88hudson-cfa marked this conversation as resolved.
Show resolved Hide resolved
writeln!(self.output.borrow_mut(), "{formatted_string}").map_err(|e| e.to_string())?;
flush()?;
Ok(())
}

fn build_cli(&self) -> Command {
let mut cli = Command::new("repl")
.multicall(true)
.arg_required_else_help(true)
.subcommand_required(true)
.subcommand_value_name("DEBUGGER")
.subcommand_help_heading("IXA DEBUGGER")
.help_template("{all-args}");

for (name, handler) in &self.commands {
cli = cli.subcommand(Command::new(*name).about(handler.about()).help_template(
"{about-with-newline}\n{usage-heading}\n {usage}\n\n{all-args}{after-help}",
));
}

cli
}
}

/// Returns the current population of the simulation
struct PopulationCommand;
impl DebuggerCommand for PopulationCommand {
fn about(&self) -> &'static str {
"Get the total number of people"
}
fn handle(
&self,
context: &mut Context,
cli: &DebuggerRepl,
_matches: &ArgMatches,
) -> Result<bool, String> {
cli.writeln(&format!("{}", context.get_current_population()))?;
Ok(false)
}
}

/// Exits the debugger and continues the simulation
struct ContinueCommand;
impl DebuggerCommand for ContinueCommand {
fn about(&self) -> &'static str {
"Continue the simulation and exit the debugger"
}
fn handle(
&self,
context: &mut Context,
cli: &DebuggerRepl,
_matches: &ArgMatches,
) -> Result<bool, String> {
cli.writeln(&format!(
k88hudson-cfa marked this conversation as resolved.
Show resolved Hide resolved
"Continuing the simulation from t = {}",
context.get_current_time()
))?;
Ok(true)
}
}

fn readline() -> Result<String, String> {
k88hudson-cfa marked this conversation as resolved.
Show resolved Hide resolved
write!(std::io::stdout(), "$ ").map_err(|e| e.to_string())?;
k88hudson-cfa marked this conversation as resolved.
Show resolved Hide resolved
std::io::stdout().flush().map_err(|e| e.to_string())?;
let mut buffer = String::new();
std::io::stdin()
.read_line(&mut buffer)
.map_err(|e| e.to_string())?;
Ok(buffer)
}

fn setup_repl(line: &str, context: &mut Context) -> Result<bool, String> {
let args = shlex::split(line).ok_or("error: Invalid quoting")?;

let mut repl = DebuggerRepl::new();
repl.register_command("population", Box::new(PopulationCommand));
repl.register_command("continue", Box::new(ContinueCommand));
let matches = repl
.build_cli()
.try_get_matches_from(args)
.map_err(|e| e.to_string())?;

if let Some((command, sub_matches)) = matches.subcommand() {
// If the provided command is known, run its handler
if let Some(handler) = repl.get_command(command) {
return handler.handle(context, &repl, sub_matches);
}
// Unexpected command: print an error
return Err(format!("Unknown command: {command}"));
}

unreachable!("subcommand required");
k88hudson-cfa marked this conversation as resolved.
Show resolved Hide resolved
}

/// Starts the debugger and pauses execution
fn start_debugger(context: &mut Context) -> Result<(), IxaError> {
println!("Debugging simulation at t = {}", context.get_current_time());
loop {
let line = readline()?;
let line = line.trim();
if line.is_empty() {
continue;
}
k88hudson-cfa marked this conversation as resolved.
Show resolved Hide resolved

match setup_repl(line, context) {
Ok(quit) => {
if quit {
break;
}
}
Err(err) => {
write!(std::io::stdout(), "{err}").map_err(|e| e.to_string())?;
flush()?;
}
}
}
Ok(())
}

pub trait ContextDebugExt {
/// Schedule the simulation to pause at time t and start the debugger.
/// This will give you a REPL which allows you to inspect the state of
/// the simulation (type help to see a list of commands)
///
/// # Errors
/// Internal debugger errors e.g., reading or writing to stdin/stdout;
/// errors in Ixa are printed to stdout
fn schedule_debugger(&mut self, t: f64);
}

impl ContextDebugExt for Context {
fn schedule_debugger(&mut self, t: f64) {
self.add_plan(t, |context| {
start_debugger(context).expect("Error in debugger");
});
}
}

#[cfg(test)]
mod tests {
use assert_cmd::Command;
use predicates::str::contains;

#[test]
fn test_cli_debugger_quits() {
let mut cmd = Command::cargo_bin("runner_test_debug").unwrap();
let assert = cmd
.args(["--debugger", "1.0"])
.write_stdin("continue\n")
.assert();

assert
.success()
.stdout(contains("Debugging simulation at t = 1"))
.stdout(contains("Continuing the simulation from t = 1"));
}

#[test]
#[ignore]
fn test_cli_debugger_population() {
let assert = Command::cargo_bin("runner_test_debug")
.unwrap()
.args(["--debugger", "1.0"])
.write_stdin("population\n")
.write_stdin("continue\n")
.assert();

assert
.success()
// This doesn't seem to work for some reason
.stdout(contains("The number of people is 3"));
k88hudson-cfa marked this conversation as resolved.
Show resolved Hide resolved
}
}
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,8 @@ pub use random::{ContextRandomExt, RngId};

pub mod report;
pub use report::{ConfigReportOptions, ContextReportExt, Report};

pub mod runner;
pub use runner::{run_with_args, run_with_custom_args, BaseArgs};

pub mod debugger;
18 changes: 15 additions & 3 deletions src/runner.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use std::path::{Path, PathBuf};

use crate::context::Context;
use crate::error::IxaError;
use crate::global_properties::ContextGlobalPropertiesExt;
use crate::random::ContextRandomExt;
use crate::report::ContextReportExt;
use crate::{context::Context, debugger::ContextDebugExt};
use clap::{Args, Command, FromArgMatches as _};

/// Default cli arguments for ixa runner
Expand All @@ -21,6 +21,10 @@ pub struct BaseArgs {
/// Optional path for report output
#[arg(short, long, default_value = "")]
pub output_dir: String,

/// Set a breakpoint at a given time and start the debugger. Defaults to t=0.0
#[arg(short, long)]
pub debugger: Option<Option<f64>>,
}

#[derive(Args)]
Expand Down Expand Up @@ -104,6 +108,11 @@ where

context.init_random(args.random_seed);

// If a breakpoint is provided, stop at that time
if let Some(t) = args.debugger {
context.schedule_debugger(t.unwrap_or(0.0));
}

// Run the provided Fn
setup_fn(&mut context, args, custom_args)?;

Expand All @@ -116,7 +125,6 @@ where
mod tests {
use super::*;
use crate::{define_global_property, define_rng};
use assert_cmd::Command;
use serde::Deserialize;

#[derive(Args, Debug)]
Expand All @@ -135,7 +143,7 @@ mod tests {
fn test_cli_invocation_with_custom_args() {
// Note this target is defined in the bin section of Cargo.toml
// and the entry point is in tests/bin/runner_test_custom_args
Command::cargo_bin("runner_test_custom_args")
assert_cmd::Command::cargo_bin("runner_test_custom_args")
.unwrap()
.args(["--field", "42"])
.assert()
Expand All @@ -155,6 +163,7 @@ mod tests {
random_seed: 42,
config: String::new(),
output_dir: String::new(),
debugger: None,
};

// Use a comparison context to verify the random seed was set
Expand Down Expand Up @@ -183,6 +192,7 @@ mod tests {
random_seed: 42,
config: "tests/data/global_properties_runner.json".to_string(),
output_dir: String::new(),
debugger: None,
};
let result = run_with_args_internal(test_args, None, |ctx, _, _: Option<()>| {
let p3 = ctx.get_global_property_value(RunnerProperty).unwrap();
Expand All @@ -198,6 +208,7 @@ mod tests {
random_seed: 42,
config: String::new(),
output_dir: "data".to_string(),
debugger: None,
};
let result = run_with_args_internal(test_args, None, |ctx, _, _: Option<()>| {
let output_dir = &ctx.report_options().directory;
Expand All @@ -213,6 +224,7 @@ mod tests {
random_seed: 42,
config: String::new(),
output_dir: String::new(),
debugger: None,
};
let custom = CustomArgs { field: 42 };
let result = run_with_args_internal(test_args, Some(custom), |_, _, c| {
Expand Down
12 changes: 12 additions & 0 deletions tests/bin/runner_test_debug.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
use ixa::runner::run_with_args;
use ixa::ContextPeopleExt;
fn main() {
run_with_args(|context, _args, _| {
context.add_person(()).unwrap();
context.add_person(()).unwrap();
context.add_person(()).unwrap();

Ok(())
})
.unwrap();
}
Loading