Skip to content

Commit c5b0e94

Browse files
committed
special case error message for command not found
1 parent ad1ede2 commit c5b0e94

File tree

13 files changed

+298
-225
lines changed

13 files changed

+298
-225
lines changed

tmc-langs-cli/src/main.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,16 @@ fn main() {
3232
env_logger::init();
3333

3434
if let Err(e) = run() {
35-
let mut causes = vec![];
35+
let mut causes = vec![e.to_string()];
3636
let mut next_source = e.source();
3737
while let Some(source) = next_source {
3838
causes.push(format!("Caused by: {}", source.to_string()));
3939
next_source = source.source();
4040
}
41+
let message = error_message_special_casing(e);
4142
let error_output = Output {
4243
status: Status::Finished,
43-
message: Some(e.to_string()),
44+
message: Some(message),
4445
result: OutputResult::Error,
4546
data: Some(causes),
4647
percent_done: 1.0,
@@ -61,6 +62,18 @@ fn main() {
6162
}
6263
}
6364

65+
// goes through the error chain and returns the first special cased error message, if any
66+
fn error_message_special_casing(e: anyhow::Error) -> String {
67+
use tmc_langs_framework::error::CommandNotFound;
68+
for cause in e.chain() {
69+
// command not found errors are special cased to notify the user that they may need to install additional software
70+
if let Some(cnf) = cause.downcast_ref::<CommandNotFound>() {
71+
return cnf.to_string();
72+
}
73+
}
74+
e.to_string()
75+
}
76+
6477
fn run() -> Result<()> {
6578
let matches = app::create_app().get_matches();
6679

tmc-langs-csharp/src/error.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
use std::path::PathBuf;
33
use std::process::ExitStatus;
44
use thiserror::Error;
5-
use tmc_langs_framework::{zip, TmcError};
5+
use tmc_langs_framework::{error::CommandNotFound, zip, TmcError};
66

77
#[derive(Debug, Error)]
88
pub enum CSharpError {
@@ -34,6 +34,9 @@ pub enum CSharpError {
3434
RunFailed(&'static str, #[source] std::io::Error),
3535
#[error("Command {0} failed with return code {1}")]
3636
CommandFailed(&'static str, ExitStatus),
37+
38+
#[error("Command not found")]
39+
CommandNotFound(#[from] CommandNotFound),
3740
}
3841

3942
impl From<CSharpError> for TmcError {

tmc-langs-csharp/src/plugin.rs

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ pub use policy::CSharpStudentFilePolicy;
66
use crate::{CSTestResult, CSharpError};
77

88
use tmc_langs_framework::{
9+
command::{OutputWithTimeout, TmcCommand},
910
domain::{
1011
ExerciseDesc, RunResult, RunStatus, Strategy, TestDesc, TestResult, ValidationResult,
1112
},
1213
plugin::Language,
1314
zip::ZipArchive,
14-
CommandWithTimeout, LanguagePlugin, OutputWithTimeout, TmcError,
15+
LanguagePlugin, TmcError,
1516
};
1617

1718
use std::collections::HashMap;
@@ -20,7 +21,6 @@ use std::ffi::{OsStr, OsString};
2021
use std::fs::{self, File};
2122
use std::io::{BufReader, Cursor, Read, Seek, Write};
2223
use std::path::{Path, PathBuf};
23-
use std::process::Command;
2424
use std::time::Duration;
2525
use walkdir::WalkDir;
2626

@@ -155,12 +155,12 @@ impl LanguagePlugin for CSharpPlugin {
155155

156156
// runs --generate-points-file and parses the generated .tmc_available_points.json
157157
fn scan_exercise(&self, path: &Path, exercise_name: String) -> Result<ExerciseDesc, TmcError> {
158-
let output = Command::new("dotnet")
158+
let mut command = TmcCommand::new("dotnet");
159+
command
159160
.current_dir(path)
160161
.arg(Self::get_bootstrap_path()?)
161-
.arg("--generate-points-file")
162-
.output()
163-
.map_err(|e| CSharpError::RunFailed("dotnet", e))?;
162+
.arg("--generate-points-file");
163+
let output = command.output()?;
164164
log::trace!("stdout: {}", String::from_utf8_lossy(&output.stdout));
165165
log::debug!("stderr: {}", String::from_utf8_lossy(&output.stderr));
166166
if !output.status.success() {
@@ -195,26 +195,19 @@ impl LanguagePlugin for CSharpPlugin {
195195
fs::remove_file(&test_results_path)
196196
.map_err(|e| CSharpError::RemoveFile(test_results_path.clone(), e))?;
197197
}
198-
let output = match CommandWithTimeout(
199-
Command::new("dotnet")
200-
.current_dir(path)
201-
.arg(Self::get_bootstrap_path()?)
202-
.arg("--run-tests"),
203-
)
204-
.wait_with_timeout("dotnet", timeout)
205-
{
206-
Ok(output) => output,
207-
Err(err) => {
208-
log::error!("Error running dotnet: {}", err);
209-
return Ok(RunResult {
210-
status: RunStatus::GenericError,
211-
test_results: vec![],
212-
logs: HashMap::new(),
213-
});
214-
}
198+
let mut command = TmcCommand::new("dotnet");
199+
command
200+
.current_dir(path)
201+
.arg(Self::get_bootstrap_path()?)
202+
.arg("--run-tests");
203+
let output = if let Some(timeout) = timeout {
204+
let output = command.wait_with_timeout(timeout)?;
205+
log::trace!("stdout: {}", String::from_utf8_lossy(output.stdout()));
206+
log::debug!("stderr: {}", String::from_utf8_lossy(output.stderr()));
207+
output
208+
} else {
209+
OutputWithTimeout::Output(command.output()?)
215210
};
216-
log::trace!("stdout: {}", String::from_utf8_lossy(output.stdout()));
217-
log::debug!("stderr: {}", String::from_utf8_lossy(output.stderr()));
218211

219212
match output {
220213
OutputWithTimeout::Output(output) => {

tmc-langs-framework/src/command.rs

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
//! Custom wrapper for Command that supports timeouts and contains custom error handling.
2+
3+
use crate::{Result, TmcError};
4+
use std::io::Read;
5+
use std::ops::{Deref, DerefMut};
6+
use std::path::PathBuf;
7+
use std::process::{Command, ExitStatus, Output, Stdio};
8+
use std::thread;
9+
use std::time::{Duration, Instant};
10+
11+
// todo: collect args?
12+
pub struct TmcCommand {
13+
name: &'static str,
14+
path: PathBuf,
15+
command: Command,
16+
}
17+
18+
impl TmcCommand {
19+
pub fn new(name: &'static str) -> Self {
20+
let path = PathBuf::from(name);
21+
Self {
22+
command: Command::new(&path),
23+
name,
24+
path,
25+
}
26+
}
27+
28+
pub fn named<P: Into<PathBuf>>(name: &'static str, path: P) -> Self {
29+
let path = path.into();
30+
Self {
31+
command: Command::new(&path),
32+
name,
33+
path,
34+
}
35+
}
36+
37+
// shadows command's status
38+
pub fn status(&mut self) -> Result<ExitStatus> {
39+
self.deref_mut().status().map_err(|e| {
40+
if let std::io::ErrorKind::NotFound = e.kind() {
41+
TmcError::CommandNotFound(crate::error::CommandNotFound {
42+
name: self.name,
43+
path: self.path.clone(),
44+
source: e,
45+
})
46+
} else {
47+
TmcError::CommandFailed(self.name, e)
48+
}
49+
})
50+
}
51+
52+
// shadows command's output
53+
pub fn output(&mut self) -> Result<Output> {
54+
self.deref_mut().output().map_err(|e| {
55+
if let std::io::ErrorKind::NotFound = e.kind() {
56+
TmcError::CommandNotFound(crate::error::CommandNotFound {
57+
name: self.name,
58+
path: self.path.clone(),
59+
source: e,
60+
})
61+
} else {
62+
TmcError::CommandFailed(self.name, e)
63+
}
64+
})
65+
}
66+
67+
/// Waits with the given timeout. Sets stdout and stderr in order to capture them after erroring.
68+
pub fn wait_with_timeout(&mut self, timeout: Duration) -> Result<OutputWithTimeout> {
69+
// spawn process and init timer
70+
let mut child = self
71+
.stdout(Stdio::piped())
72+
.stderr(Stdio::piped())
73+
.spawn()
74+
.map_err(|e| TmcError::CommandSpawn(self.name, e))?;
75+
let timer = Instant::now();
76+
77+
loop {
78+
match child.try_wait().map_err(TmcError::Process)? {
79+
Some(_exit_status) => {
80+
// done, get output
81+
return child
82+
.wait_with_output()
83+
.map(OutputWithTimeout::Output)
84+
.map_err(|e| {
85+
if let std::io::ErrorKind::NotFound = e.kind() {
86+
TmcError::CommandNotFound(crate::error::CommandNotFound {
87+
name: self.name,
88+
path: self.path.clone(),
89+
source: e,
90+
})
91+
} else {
92+
TmcError::CommandFailed(self.name, e)
93+
}
94+
});
95+
}
96+
None => {
97+
// still running, check timeout
98+
if timer.elapsed() > timeout {
99+
log::warn!("command {} timed out", self.name);
100+
// todo: cleaner method for killing
101+
child.kill().map_err(TmcError::Process)?;
102+
103+
let mut stdout = vec![];
104+
let mut stderr = vec![];
105+
let stdout_handle = child.stdout.as_mut().unwrap();
106+
let stderr_handle = child.stderr.as_mut().unwrap();
107+
stdout_handle.read_to_end(&mut stdout).unwrap();
108+
stderr_handle.read_to_end(&mut stderr).unwrap();
109+
return Ok(OutputWithTimeout::Timeout { stdout, stderr });
110+
}
111+
112+
// TODO: gradually increase sleep duration?
113+
thread::sleep(Duration::from_millis(100));
114+
}
115+
}
116+
}
117+
}
118+
}
119+
120+
impl Deref for TmcCommand {
121+
type Target = Command;
122+
123+
fn deref(&self) -> &Self::Target {
124+
&self.command
125+
}
126+
}
127+
128+
impl DerefMut for TmcCommand {
129+
fn deref_mut(&mut self) -> &mut Self::Target {
130+
&mut self.command
131+
}
132+
}
133+
134+
pub enum OutputWithTimeout {
135+
Output(Output),
136+
Timeout { stdout: Vec<u8>, stderr: Vec<u8> },
137+
}
138+
139+
impl OutputWithTimeout {
140+
pub fn stdout(&self) -> &[u8] {
141+
match self {
142+
Self::Output(output) => &output.stdout,
143+
Self::Timeout { stdout, .. } => &stdout,
144+
}
145+
}
146+
pub fn stderr(&self) -> &[u8] {
147+
match self {
148+
Self::Output(output) => &output.stderr,
149+
Self::Timeout { stderr, .. } => &stderr,
150+
}
151+
}
152+
}
153+
154+
#[cfg(test)]
155+
mod test {
156+
use super::*;
157+
158+
#[test]
159+
fn timeout() {
160+
let mut cmd = TmcCommand::new("sleep");
161+
cmd.arg("1");
162+
let res = cmd.wait_with_timeout(Duration::from_millis(100)).unwrap();
163+
if let OutputWithTimeout::Timeout { .. } = res {
164+
} else {
165+
panic!("unexpected result");
166+
}
167+
}
168+
}

tmc-langs-framework/src/error.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,25 @@ pub enum TmcError {
6565
#[error("Failed to spawn command: {0}")]
6666
CommandSpawn(&'static str, #[source] std::io::Error),
6767

68-
#[error("Error in plugin: {0}")]
69-
Plugin(#[source] Box<dyn std::error::Error + 'static + Send + Sync>),
68+
#[error("Error in plugin")]
69+
Plugin(#[from] Box<dyn std::error::Error + 'static + Send + Sync>),
7070

7171
#[error(transparent)]
7272
YamlDeserialization(#[from] serde_yaml::Error),
7373
#[error(transparent)]
7474
ZipError(#[from] tmc_zip::ZipError),
7575
#[error(transparent)]
7676
WalkDir(#[from] walkdir::Error),
77+
78+
#[error("Command not found")]
79+
CommandNotFound(#[from] CommandNotFound),
80+
}
81+
82+
// == Collection of errors likely to be useful in multiple plugins which can be special cased without needing a plugin's specific error type ==
83+
#[derive(Error, Debug)]
84+
#[error("The executable for \"{name}\" could not be found ({path}). Please make sure you have installed it correctly.")]
85+
pub struct CommandNotFound {
86+
pub name: &'static str,
87+
pub path: PathBuf,
88+
pub source: std::io::Error,
7789
}

0 commit comments

Comments
 (0)