From 957053b1efca3b2452006cbaf0d5f1b0f8e26837 Mon Sep 17 00:00:00 2001 From: Killertux Date: Thu, 10 Sep 2026 13:43:37 -0300 Subject: [PATCH] Add SliM runtime controls and transports --- rust_slim/readme.md | 37 +- rust_slim/src/lib.rs | 128 ++++--- rust_slim/src/server.rs | 565 ++++++++++++++++++++++++++++- rust_slim/src/value.rs | 8 +- rust_slim/tests/fixture_macro.rs | 41 ++- rust_slim_macros/src/lib.rs | 40 +- slim_protocol/src/lib.rs | 47 +++ temoc/src/slim_server_connector.rs | 117 ++++-- 8 files changed, 881 insertions(+), 102 deletions(-) diff --git a/rust_slim/readme.md b/rust_slim/readme.md index ead09ba..c7931ce 100644 --- a/rust_slim/readme.md +++ b/rust_slim/readme.md @@ -2,9 +2,11 @@ Develop Slim Fixtures for rust applications. Based on the [Slim Protocol](https://fitnesse.org/FitNesse/UserGuide/WritingAcceptanceTests/SliM/SlimProtocol.html) of fitnesse [fitnesse](https://fitnesse.org) . -This is not 100% compliant with the SliM protocol yet. Remaining work includes -instruction timeouts, stop and ignore batch control, standard input/output -transport with fixture-output tunneling, and the optional HTML hash converter. +This is not 100% compliant with the SliM protocol yet. The optional HTML hash +converter and full FitNesse end-to-end compatibility coverage remain. The +runtime intentionally provides an observational timeout instead of +isolation-backed hard cancellation, and explicit output tunnels instead of +transparent process-wide stdio capture; those limitations are described below. This is currently in an unstable version. The general API can change in the next versions. @@ -24,3 +26,32 @@ Manual fixtures must also implement `Constructor::construct` and return constructor for `Default` fixtures. Mark one or more associated functions with `#[slim(constructor)]` to expose typed constructors of different arities, and mark an `&mut self` accessor with `#[slim(sut)]` to provide a System Under Test. + +## Runtime controls, timeouts, and transports + +Fixtures can stop the remainder of the current instruction batch by returning +`ExecuteMethodError::Control`. Use `SlimControl::abort_slim_test`, +`abort_slim_suite`, `ignore_script_test`, or `ignore_all_tests`; each produces +the corresponding V0.5 exception tag and an optional `message:<<...>>` reason. +The next batch on the same connection remains usable. + +Create a transport with `PortSlimServer::listen_from_args(std::env::args().skip(1))`. +It parses FitNesse's documented `-s ` timeout flag followed by the port. +Port `1` uses stdin/stdout; other ports bind `0.0.0.0:port`, accept one TCP +connection, and then run the normal V0.5 handshake. Responses and client +requests are flushed after every frame. Applications that already parse their +arguments can use `listen_on_port(port, options)` directly. + +The timeout is observational: the single-threaded server measures elapsed time +after an instruction returns and emits `TIMED_OUT n` for an overrun. Arbitrary +Rust fixture code is not forcibly cancelled, so effects it made before returning +remain. A control exception takes precedence over a simultaneous timeout. This +avoids unsound cross-thread cancellation; fixtures that require prompt +cancellation must expose a cooperative mechanism. + +When using port `1`, protocol stdout cannot also carry fixture output. Stable +Rust cannot intercept arbitrary process-wide `println!` or stderr writes +without platform-specific descriptor manipulation. Write intentional fixture +output to `OutputTunnel::stdout(std::io::stderr())` or +`OutputTunnel::stderr(std::io::stderr())`; it emits the V0.5 `SOUT :`/ +`SOUT.:` and `SERR :`/`SERR.:` prefixes line by line. diff --git a/rust_slim/src/lib.rs b/rust_slim/src/lib.rs index aa8f704..b75efbf 100644 --- a/rust_slim/src/lib.rs +++ b/rust_slim/src/lib.rs @@ -55,67 +55,18 @@ //! Than, we need to add an entrypoint to the slim server so we can run it. There are lot of ways of doing this. One is by creating an example in your project. //! //! So create and example file called `calculator.rs` and add this: -//! ```rust -//! use rust_slim::SlimServer; -//! use std::net::TcpListener; -//! use anyhow::Result; +//! ```rust,no_run +//! use rust_slim::PortSlimServer; //! use std::env::args; -//! # use std::net::TcpStream; -//! # use std::thread::spawn; -//! # use std::io::Write; -//! # use rust_slim::fixture; //! +//! # use anyhow::Result; +//! # use rust_slim::fixture; //! # #[derive(Default)] -//! # pub struct Calculator { -//! # a: i64, -//! # b: i64, -//! # } -//! # +//! # pub struct Calculator; //! # #[fixture] -//! # impl Calculator { -//! # #[slim(constructor)] -//! # pub fn new() -> Self { -//! # Self::default() -//! # } -//! # -//! # pub fn set_a(&mut self, a: i64) { -//! # self.a = a -//! # } -//! # -//! # pub fn set_b(&mut self, b: i64) { -//! # self.b = b -//! # } -//! # -//! # pub fn sum(&self) -> i64 { -//! # self.a + self.b -//! # } -//! # -//! # pub fn mul(&self) -> i64 { -//! # self.a * self.b -//! # } -//! # -//! # pub fn sub(&self) -> i64 { -//! # self.a - self.b -//! # } -//! # -//! # pub fn div(&self) -> i64 { -//! # self.a / self.b -//! # } -//! # } -//! # +//! # impl Calculator {} //! fn main() -> Result<()> { -//! # spawn(|| { -//! # loop { -//! # if let Ok(mut stream) = TcpStream::connect("127.0.0.1:8085") { -//! # stream.write_all(b"000003:bye").unwrap(); -//! # break; -//! # } -//! # } -//! # }); -//! let port = args().skip(1).next().unwrap_or("8085".to_string()); -//! let listener = TcpListener::bind(format!("0.0.0.0:{port}").to_string()).expect("Error"); -//! let (stream, _) = listener.accept()?; -//! let mut server = SlimServer::new(stream.try_clone()?, stream); +//! let mut server = PortSlimServer::listen_from_args(args().skip(1))?; //! server.add_fixture::(); //! server.run()?; //! Ok(()) @@ -125,7 +76,10 @@ #[cfg(feature = "macros")] pub use rust_slim_macros::*; -pub use server::SlimServer; +pub use server::{ + OutputTunnel, PortSlimServer, SlimServer, SlimServerError, SlimServerOptions, + SlimServerOptionsError, +}; use std::fmt::{Display, Formatter}; pub use to_slim_result_string::*; pub use utils::from_rust_module_path_to_class_path; @@ -196,6 +150,63 @@ pub enum ConstructorError { NoConstructor, ArgumentParsingError(String), CouldNotInvoke(String), + /// A constructor asks the runtime to stop or ignore its current batch. + Control(SlimControlException), +} + +/// A structured request from a fixture to change SliM batch execution. +/// +/// The reference implementation recognizes exception class names such as +/// `StopTest`. Rust fixtures express the same request by returning this value +/// through [`ExecuteMethodError::Control`]. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum SlimControl { + AbortSlimTest, + AbortSlimSuite, + IgnoreScriptTest, + IgnoreAllTests, +} + +impl SlimControl { + pub fn abort_slim_test(message: impl Into) -> SlimControlException { + SlimControlException::new(Self::AbortSlimTest, message) + } + + pub fn abort_slim_suite(message: impl Into) -> SlimControlException { + SlimControlException::new(Self::AbortSlimSuite, message) + } + + pub fn ignore_script_test(message: impl Into) -> SlimControlException { + SlimControlException::new(Self::IgnoreScriptTest, message) + } + + pub fn ignore_all_tests(message: impl Into) -> SlimControlException { + SlimControlException::new(Self::IgnoreAllTests, message) + } +} + +/// Details for a fixture-requested SliM batch control result. +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct SlimControlException { + pub control: SlimControl, + pub message: Option, +} + +impl SlimControlException { + pub fn new(control: SlimControl, message: impl Into) -> Self { + let message = message.into(); + Self { + control, + message: (!message.is_empty()).then_some(message), + } + } + + pub fn without_message(control: SlimControl) -> Self { + Self { + control, + message: None, + } + } } /// Error that can happen while trying to execute a method in a feature. @@ -207,6 +218,8 @@ pub enum ExecuteMethodError { ArgumentParsingError(String), /// And there might be some failure in the method itself, which should cause an ExecutionError. ExecutionError(String), + /// A fixture asks the SliM runtime to stop or ignore the current work. + Control(SlimControlException), } impl ExecuteMethodError { @@ -231,6 +244,7 @@ impl Display for ExecuteMethodError { write!(f, "NO_CONVERTER_FOR_ARGUMENT_NUMBER {argument}") } ExecuteMethodError::ExecutionError(error) => f.write_str(error), + ExecuteMethodError::Control(control) => write!(f, "{:?}", control.control), } } } diff --git a/rust_slim/src/server.rs b/rust_slim/src/server.rs index 7296e89..22f4623 100644 --- a/rust_slim/src/server.rs +++ b/rust_slim/src/server.rs @@ -1,6 +1,6 @@ use crate::{ - ClassPath, Constructor, ConstructorError, ExecuteMethodError, SlimFixture, SlimObject, - SlimValue, + ClassPath, Constructor, ConstructorError, ExecuteMethodError, SlimControl, + SlimControlException, SlimFixture, SlimObject, SlimValue, }; use convert_case::{Case, Casing}; use slim_protocol::{ @@ -10,6 +10,8 @@ use slim_protocol::{ use std::{ collections::HashMap, io::{BufReader, Read, Write}, + net::TcpListener, + time::{Duration, Instant}, }; use thiserror::Error; @@ -22,6 +24,92 @@ pub enum SlimServerError { IoError(#[from] std::io::Error), #[error(transparent)] FromSlimReaderError(#[from] FromSlimReaderError), + #[error(transparent)] + Options(#[from] SlimServerOptionsError), +} + +/// Server configuration understood by the V0.5 runtime. +/// +/// `instruction_timeout` is an *observational* timeout. Fixtures execute on +/// the server thread and may own `Rc>` state, so Rust cannot safely +/// cancel arbitrary fixture code. Once an instruction returns, its elapsed +/// time is checked and an overrun is reported as `TIMED_OUT `. +/// Side effects performed before the return remain visible and the runtime +/// continues with the next instruction. Fixtures that need prompt stopping +/// must implement cooperative cancellation in their own APIs. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SlimServerOptions { + instruction_timeout: Option, +} + +impl SlimServerOptions { + pub fn with_instruction_timeout_seconds(seconds: u64) -> Self { + Self { + instruction_timeout: Some(Duration::from_secs(seconds)), + } + } + + pub fn instruction_timeout(&self) -> Option { + self.instruction_timeout + } + + /// Parses the documented `-s ` SliM flag. Other arguments are + /// ignored so the same slice can include the command-line port. + pub fn from_slim_flags(flags: &str) -> Result { + Self::from_args(flags.split_whitespace()) + } + + pub fn from_args(args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let values = args + .into_iter() + .map(|value| value.as_ref().to_owned()) + .collect::>(); + let mut options = Self::default(); + let mut index = 0; + while index < values.len() { + let value = &values[index]; + let seconds = if value == "-s" { + index += 1; + values + .get(index) + .ok_or(SlimServerOptionsError::MissingTimeout)? + } else if let Some(seconds) = value.strip_prefix("-s") { + if seconds.is_empty() { + index += 1; + values + .get(index) + .ok_or(SlimServerOptionsError::MissingTimeout)? + } else { + seconds + } + } else { + index += 1; + continue; + }; + let seconds = seconds + .parse::() + .map_err(|_| SlimServerOptionsError::InvalidTimeout(seconds.to_owned()))?; + options.instruction_timeout = Some(Duration::from_secs(seconds)); + index += 1; + } + Ok(options) + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum SlimServerOptionsError { + #[error("the -s SliM timeout flag needs a number of seconds")] + MissingTimeout, + #[error("invalid -s SliM timeout value: {0}")] + InvalidTimeout(String), + #[error("the SliM server command line needs a port")] + MissingPort, + #[error("invalid SliM server port: {0}")] + InvalidPort(String), } /// The mutable execution context required by the SliM instruction set. @@ -35,10 +123,15 @@ pub struct SlimServer { imports: Vec, reader: BufReader, writer: W, + options: SlimServerOptions, } impl SlimServer { pub fn new(reader: R, writer: W) -> Self { + Self::with_options(reader, writer, SlimServerOptions::default()) + } + + pub fn with_options(reader: R, writer: W, options: SlimServerOptions) -> Self { Self { fixtures: HashMap::new(), instances: HashMap::new(), @@ -48,6 +141,7 @@ impl SlimServer { imports: Vec::new(), reader: BufReader::new(reader), writer, + options, } } @@ -77,10 +171,33 @@ impl SlimServer { } fn execute_instructions(&mut self, instructions: Vec) -> Vec { - instructions - .into_iter() - .map(|instruction| self.execute_instruction(instruction)) - .collect() + let mut results = Vec::with_capacity(instructions.len()); + for instruction in instructions { + let timeout_eligible = matches!( + &instruction, + Instruction::Make { .. } + | Instruction::Call { .. } + | Instruction::CallAndAssign { .. } + ); + let started = Instant::now(); + let mut result = self.execute_instruction(instruction); + if timeout_eligible && control_from_result(&result).is_none() { + if let Some(timeout) = self.options.instruction_timeout { + if started.elapsed() >= timeout { + result = exception( + result.id.clone(), + format!("TIMED_OUT {}", timeout.as_secs()), + ); + } + } + } + let control = control_from_result(&result); + results.push(result); + if control.is_some() { + break; + } + } + results } fn execute_instruction(&mut self, instruction: Instruction) -> InstructionResult { @@ -172,6 +289,7 @@ impl SlimServer { id, format!("COULD_NOT_INVOKE_CONSTRUCTOR {class} message:<<{message}>>"), ), + Err(ConstructorError::Control(control)) => control_exception(id, control), } } @@ -209,6 +327,7 @@ impl SlimServer { } .to_string(), ), + Err(ExecuteMethodError::Control(control)) => control_exception(id, control), Err(error) => exception(id, error.to_string()), } } @@ -371,6 +490,133 @@ impl SlimServer { } } +/// A server whose transport is selected from the SliM command-line port. +pub type PortSlimServer = SlimServer, Box>; + +impl SlimServer, Box> { + /// Parses FitNesse's `[-s seconds] port` arguments and opens the selected + /// transport. Pass `std::env::args().skip(1)` from a server binary. + pub fn listen_from_args(args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let values = args + .into_iter() + .map(|value| value.as_ref().to_owned()) + .collect::>(); + let port_value = values.last().ok_or(SlimServerOptionsError::MissingPort)?; + let port = port_value + .parse::() + .map_err(|_| SlimServerOptionsError::InvalidPort(port_value.clone()))?; + let options = SlimServerOptions::from_args(&values)?; + Self::listen_on_port(port, options) + } + + /// Creates a V0.5 server transport for the supplied SliM port. + /// + /// Port `1` uses the current process stdin/stdout. Any other port binds a + /// TCP listener, accepts one FitNesse connection, then returns a server + /// ready for fixture registration and [`SlimServer::run`]. + pub fn listen_on_port(port: u16, options: SlimServerOptions) -> Result { + if port == 1 { + return Ok(Self::with_options( + Box::new(std::io::stdin()), + Box::new(std::io::stdout()), + options, + )); + } + let listener = TcpListener::bind(("0.0.0.0", port))?; + let (stream, _) = listener.accept()?; + Ok(Self::with_options( + Box::new(stream.try_clone()?), + Box::new(stream), + options, + )) + } +} + +/// A line-oriented output tunnel for fixtures running in stdio mode. +/// +/// SliM reserves stdout for protocol frames when port `1` is used. Stable +/// Rust cannot safely redirect arbitrary process-wide stdout/stderr without +/// platform-specific file-descriptor manipulation, so fixtures that produce +/// output should write to this explicit adapter, targeting stderr. It emits +/// the V0.5 `SOUT :`/`SOUT.:` or `SERR :`/`SERR.:` prefixes exactly. +pub struct OutputTunnel { + destination: W, + first_line: bool, + at_line_start: bool, + stream: OutputStream, +} + +#[derive(Clone, Copy)] +enum OutputStream { + Stdout, + Stderr, +} + +impl OutputTunnel { + pub fn stdout(destination: W) -> Self { + Self { + destination, + first_line: true, + at_line_start: true, + stream: OutputStream::Stdout, + } + } + + pub fn stderr(destination: W) -> Self { + Self { + destination, + first_line: true, + at_line_start: true, + stream: OutputStream::Stderr, + } + } + + pub fn into_inner(self) -> W { + self.destination + } + + fn prefix(&self) -> &'static [u8] { + match (self.stream, self.first_line) { + (OutputStream::Stdout, true) => b"SOUT :", + (OutputStream::Stdout, false) => b"SOUT.:", + (OutputStream::Stderr, true) => b"SERR :", + (OutputStream::Stderr, false) => b"SERR.:", + } + } +} + +impl Write for OutputTunnel { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + for byte in buffer { + if self.at_line_start { + self.destination.write_all(self.prefix())?; + self.at_line_start = false; + self.first_line = false; + } + self.destination.write_all(std::slice::from_ref(byte))?; + if *byte == b'\n' { + self.at_line_start = true; + } + } + Ok(buffer.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.destination.flush()?; + // A flush after a completed line delimits one fixture log message. + // Do not reset an unfinished line: doing so would inject a prefix in + // the middle of its text on the next partial write. + if self.at_line_start { + self.first_line = true; + } + Ok(()) + } +} + fn invoke( object: &SlimObject, method: &str, @@ -398,6 +644,35 @@ fn exception(id: Id, value: String) -> InstructionResult { InstructionResult::exception(id, ExceptionMessage::new(value)) } +fn control_exception(id: Id, control: SlimControlException) -> InstructionResult { + let tag = match control.control { + SlimControl::AbortSlimTest => "ABORT_SLIM_TEST", + SlimControl::AbortSlimSuite => "ABORT_SLIM_SUITE", + SlimControl::IgnoreScriptTest => "IGNORE_SCRIPT_TEST", + SlimControl::IgnoreAllTests => "IGNORE_ALL_TESTS", + }; + let message = control + .message + .map(|message| format!("message:<<{message}>>")) + .unwrap_or_default(); + exception(id, format!("{tag}:{message}")) +} + +fn control_from_result(result: &InstructionResult) -> Option { + let InstructionResultValue::Exception(message) = &result.value else { + return None; + }; + let message = message.raw_message(); + [ + ("ABORT_SLIM_TEST:", SlimControl::AbortSlimTest), + ("ABORT_SLIM_SUITE:", SlimControl::AbortSlimSuite), + ("IGNORE_SCRIPT_TEST:", SlimControl::IgnoreScriptTest), + ("IGNORE_ALL_TESTS:", SlimControl::IgnoreAllTests), + ] + .into_iter() + .find_map(|(tag, control)| message.starts_with(tag).then_some(control)) +} + fn malformed_instruction(id: Id, fields: Vec) -> InstructionResult { exception(id, format!("MALFORMED_INSTRUCTION [{}]", fields.join(","))) } @@ -451,7 +726,7 @@ fn instruction_result_value(value: SlimValue) -> InstructionResultValue { #[cfg(test)] mod tests { use super::*; - use std::io::Cursor; + use std::{io::Cursor, thread, time::Duration}; struct Fixture { value: String, @@ -553,10 +828,29 @@ mod tests { method: &str, _args: Vec, ) -> Result { - Err(ExecuteMethodError::MethodNotFound { - method: method.into(), - class: "FailingFixture".into(), - }) + match method { + "abort_test" => Err(ExecuteMethodError::Control(SlimControl::abort_slim_test( + "stop this test", + ))), + "abort_suite" => Err(ExecuteMethodError::Control(SlimControl::abort_slim_suite( + "stop this suite", + ))), + "ignore_script" => Err(ExecuteMethodError::Control( + SlimControl::ignore_script_test("ignore this script"), + )), + "ignore_all" => Err(ExecuteMethodError::Control(SlimControl::ignore_all_tests( + "ignore this test", + ))), + "slow" => { + thread::sleep(Duration::from_millis(1_050)); + Ok(SlimValue::String("finished late".into())) + } + "after" => Ok(SlimValue::String("after".into())), + _ => Err(ExecuteMethodError::MethodNotFound { + method: method.into(), + class: "FailingFixture".into(), + }), + } } } impl ClassPath for Fixture { @@ -601,12 +895,16 @@ mod tests { impl Constructor for FailingFixture { fn construct(args: Vec) -> Result { match args.as_slice() { + [] => Ok(Self), [SlimValue::String(value)] if value == "conversion" => { Err(ConstructorError::ArgumentParsingError("i64".into())) } [SlimValue::String(value)] if value == "invocation" => Err( ConstructorError::CouldNotInvoke("constructor failed".into()), ), + [SlimValue::String(value)] if value == "control" => Err(ConstructorError::Control( + SlimControl::abort_slim_test("constructor stopped"), + )), _ => Err(ConstructorError::NoConstructor), } } @@ -1125,6 +1423,251 @@ mod tests { ); } + #[test] + fn control_exceptions_have_exact_tags_and_stop_only_the_current_batch() { + let controls = [ + ("abort_test", "ABORT_SLIM_TEST:message:<>"), + ( + "abort_suite", + "ABORT_SLIM_SUITE:message:<>", + ), + ( + "ignore_script", + "IGNORE_SCRIPT_TEST:message:<>", + ), + ( + "ignore_all", + "IGNORE_ALL_TESTS:message:<>", + ), + ]; + + for (method, expected) in controls { + let mut server = server(); + assert_eq!( + vec![InstructionResult::ok("make".into())], + server.execute_instructions(vec![Instruction::Make { + id: "make".into(), + instance: "failing".into(), + class: "FailingFixture".into(), + args: vec![], + }]) + ); + let batch = server.execute_instructions(vec![ + call("control", "failing", method, vec![]), + call("skipped", "failing", "after", vec![]), + ]); + assert_eq!(1, batch.len()); + assert_eq!( + InstructionResult::exception( + "control".into(), + ExceptionMessage::new(expected.into()) + ), + batch[0] + ); + assert_eq!( + vec![InstructionResult::string("next".into(), "after".into())], + server.execute_instructions(vec![call("next", "failing", "after", vec![])]) + ); + } + + let mut server = server(); + let result = server.execute_instructions(vec![ + Instruction::Make { + id: "constructor-control".into(), + instance: "never-created".into(), + class: "FailingFixture".into(), + args: vec!["control".into()], + }, + Instruction::Import { + id: "skipped".into(), + path: "Skipped".into(), + }, + ]); + assert_eq!( + vec![InstructionResult::exception( + "constructor-control".into(), + ExceptionMessage::new("ABORT_SLIM_TEST:message:<>".into()), + )], + result + ); + assert!(server.imports.is_empty()); + assert_eq!( + InstructionResult::exception( + "plain".into(), + ExceptionMessage::new("IGNORE_ALL_TESTS:".into()) + ), + control_exception( + "plain".into(), + SlimControlException::without_message(SlimControl::IgnoreAllTests) + ) + ); + } + + #[test] + fn soft_timeout_reports_the_protocol_message_and_preserves_side_effects() { + let mut server = SlimServer::with_options( + Cursor::new(Vec::new()), + Cursor::new(Vec::new()), + SlimServerOptions::with_instruction_timeout_seconds(0), + ); + server.instances.insert( + "failing".into(), + SlimObject::fixture(FailingFixture, "FailingFixture"), + ); + let result = server.execute_instructions(vec![ + Instruction::Import { + id: "import".into(), + path: "Fixtures".into(), + }, + Instruction::Assign { + id: "assign".into(), + symbol: "Value".into(), + value: "value".into(), + }, + call("call", "failing", "after", vec![]), + Instruction::CallAndAssign { + id: "call-and-assign".into(), + symbol: "Late".into(), + instance: "failing".into(), + function: "after".into(), + args: vec![], + }, + ]); + assert_eq!(InstructionResult::ok("import".into()), result[0]); + assert_eq!(InstructionResult::ok("assign".into()), result[1]); + assert_eq!( + InstructionResult::exception( + "call".into(), + ExceptionMessage::new("TIMED_OUT 0".into()), + ), + result[2] + ); + assert_eq!( + InstructionResult::exception( + "call-and-assign".into(), + ExceptionMessage::new("TIMED_OUT 0".into()), + ), + result[3] + ); + assert_eq!( + Some(&SlimValue::String("value".into())), + server.symbols.get("Value") + ); + assert_eq!( + Some(&SlimValue::String("after".into())), + server.symbols.get("Late") + ); + + let control = server.execute_instructions(vec![ + call("control", "failing", "abort_test", vec![]), + call("skipped", "failing", "after", vec![]), + ]); + assert_eq!(1, control.len()); + assert_eq!( + InstructionResult::exception( + "control".into(), + ExceptionMessage::new("ABORT_SLIM_TEST:message:<>".into()), + ), + control[0] + ); + } + + #[test] + fn parses_documented_timeout_flags() { + assert_eq!( + SlimServerOptions::with_instruction_timeout_seconds(7), + SlimServerOptions::from_slim_flags("-v -s 7").unwrap() + ); + assert_eq!( + SlimServerOptions::with_instruction_timeout_seconds(3), + SlimServerOptions::from_slim_flags("-s3").unwrap() + ); + assert_eq!( + Err(SlimServerOptionsError::MissingTimeout), + SlimServerOptions::from_slim_flags("-s") + ); + + let server = PortSlimServer::listen_from_args(["-s", "9", "1"]).unwrap(); + assert_eq!( + SlimServerOptions::with_instruction_timeout_seconds(9), + server.options + ); + assert!(matches!( + PortSlimServer::listen_from_args(Vec::::new()), + Err(SlimServerError::Options( + SlimServerOptionsError::MissingPort + )) + )); + assert!(matches!( + PortSlimServer::listen_from_args(["not-a-port"]), + Err(SlimServerError::Options( + SlimServerOptionsError::InvalidPort(value) + )) if value == "not-a-port" + )); + } + + #[test] + fn output_tunnel_uses_v05_prefixes_for_each_line() { + let mut stdout = OutputTunnel::stdout(Vec::new()); + stdout.write_all(b"one\ntwo").unwrap(); + stdout.write_all(b"\nthree\n").unwrap(); + stdout.flush().unwrap(); + stdout.write_all(b"again\n").unwrap(); + assert_eq!( + b"SOUT :one\nSOUT.:two\nSOUT.:three\nSOUT :again\n", + stdout.into_inner().as_slice() + ); + + let mut stderr = OutputTunnel::stderr(Vec::new()); + stderr.write_all(b"problem\nagain").unwrap(); + stderr.flush().unwrap(); + stderr.write_all(b" still here").unwrap(); + assert_eq!( + b"SERR :problem\nSERR.:again still here", + stderr.into_inner().as_slice() + ); + } + + #[test] + fn abort_suite_does_not_close_the_connection_for_the_next_batch() { + let batches = [ + vec![Instruction::Make { + id: "make".into(), + instance: "failing".into(), + class: "FailingFixture".into(), + args: vec![], + }], + vec![ + call("abort", "failing", "abort_suite", vec![]), + call("skipped", "failing", "after", vec![]), + ], + vec![call("next", "failing", "after", vec![])], + ]; + let mut input = batches + .iter() + .flat_map(|batch| batch.to_slim_string().as_bytes().to_vec()) + .collect::>(); + input.extend_from_slice("bye".to_slim_string().as_bytes()); + let mut output = Vec::new(); + let mut server = SlimServer::new(Cursor::new(input), Cursor::new(&mut output)); + server.add_fixture::(); + server.run().unwrap(); + + let expected_batches = [ + vec![InstructionResult::ok("make".into())], + vec![InstructionResult::exception( + "abort".into(), + ExceptionMessage::new("ABORT_SLIM_SUITE:message:<>".into()), + )], + vec![InstructionResult::string("next".into(), "after".into())], + ]; + let mut expected = b"Slim -- V0.5\n".to_vec(); + for batch in expected_batches { + expected.extend_from_slice(batch.to_slim_string().as_bytes()); + } + assert_eq!(expected, output); + } + #[test] fn run_writes_a_handshake_response_and_stops_at_bye() { let mut input = vec![Instruction::Import { diff --git a/rust_slim/src/value.rs b/rust_slim/src/value.rs index 6f9363f..6b95c76 100644 --- a/rust_slim/src/value.rs +++ b/rust_slim/src/value.rs @@ -4,7 +4,7 @@ //! protocol only has strings and lists, while fixture execution also needs //! null, void, and values that stay in-process as object symbols. -use crate::ExecuteMethodError; +use crate::{ExecuteMethodError, SlimControlException}; use chrono::NaiveDate; use std::{any::Any, cell::RefCell, fmt, rc::Rc}; @@ -182,6 +182,12 @@ impl IntoSlimValue for SlimValue { } } +impl IntoSlimValue for SlimControlException { + fn into_slim_value(self) -> Result { + Err(ExecuteMethodError::Control(self)) + } +} + impl FromSlimValue for SlimObject { fn from_slim_value(value: SlimValue) -> Result { match value { diff --git a/rust_slim/tests/fixture_macro.rs b/rust_slim/tests/fixture_macro.rs index ed97b41..e4ad5be 100644 --- a/rust_slim/tests/fixture_macro.rs +++ b/rust_slim/tests/fixture_macro.rs @@ -2,7 +2,7 @@ use rust_slim::{ fixture, ClassPath, Constructor, ConstructorError, ExecuteMethodError, FromSlimValue, - IntoSlimValue, SlimFixture, SlimValue, + IntoSlimValue, SlimControl, SlimControlException, SlimFixture, SlimValue, }; use std::marker::PhantomData; @@ -27,6 +27,7 @@ struct SutFixture { struct Sut; #[derive(Debug, PartialEq)] struct MultiConstructor(i64); +struct ControlledConstructor; #[fixture] impl GenericFixture { @@ -98,6 +99,14 @@ impl MultiConstructor { } } +#[fixture] +impl ControlledConstructor { + #[slim(constructor)] + pub fn new() -> Result { + Err(SlimControl::abort_slim_test("constructor control")) + } +} + #[fixture] impl Sut { pub fn answer(&self) -> i64 { @@ -139,6 +148,14 @@ impl MacroFixture { pub fn fails(&self) -> Result<(), &'static str> { Err("fixture failed") } + + pub fn abort(&self) -> SlimControlException { + SlimControl::abort_slim_test("macro control") + } + + pub fn abort_result(&self) -> Result<(), SlimControlException> { + Err(SlimControl::ignore_all_tests("macro result control")) + } } #[test] @@ -195,6 +212,28 @@ fn macro_turns_result_errors_into_execution_errors() { ); } +#[test] +fn macro_preserves_structured_control_results() { + let mut fixture = MacroFixture; + assert_eq!( + Err(ExecuteMethodError::Control(SlimControl::abort_slim_test( + "macro control" + ))), + fixture.execute_method("abort", Vec::new()) + ); + assert_eq!( + Err(ExecuteMethodError::Control(SlimControl::ignore_all_tests( + "macro result control" + ))), + fixture.execute_method("abort_result", Vec::new()) + ); + assert!(matches!( + ControlledConstructor::construct(Vec::new()), + Err(ConstructorError::Control(control)) + if control == SlimControl::abort_slim_test("constructor control") + )); +} + #[test] fn macro_supports_generic_fixture_types() { let mut fixture = GenericFixture::(PhantomData); diff --git a/rust_slim_macros/src/lib.rs b/rust_slim_macros/src/lib.rs index 241209b..7072083 100644 --- a/rust_slim_macros/src/lib.rs +++ b/rust_slim_macros/src/lib.rs @@ -202,13 +202,24 @@ fn invocation_arm( })? } }); + let invoke = quote! { <#ty>::#ident(self, #(#arguments),*) }; + let result = if returns_slim_control_result(&method.sig.output) { + quote! { + match #invoke { + Ok(value) => ::rust_slim::IntoSlimValue::into_slim_value(value), + Err(control) => Err(::rust_slim::ExecuteMethodError::Control(control)), + } + } + } else { + quote! { ::rust_slim::IntoSlimValue::into_slim_value(#invoke) } + }; quote! { #name => { if args.len() != #arity { return Err(::rust_slim::ExecuteMethodError::MethodNotFound { method: method.to_string(), class: #class_path }); } let mut supplied = args.into_iter(); - ::rust_slim::IntoSlimValue::into_slim_value(<#ty>::#ident(self, #(#arguments),*)) + #result } } } @@ -296,7 +307,9 @@ fn constructor_arm(ty: &Type, method: &ImplItemFn) -> proc_macro2::TokenStream { } }); let invoke = quote! { <#ty>::#ident(#(#arguments),*) }; - let result = if returns_result(&method.sig.output) { + let result = if returns_slim_control_result(&method.sig.output) { + quote! { #invoke.map_err(::rust_slim::ConstructorError::Control) } + } else if returns_result(&method.sig.output) { quote! { #invoke.map_err(|error| ::rust_slim::ConstructorError::CouldNotInvoke(error.to_string())) } } else { quote! { Ok(#invoke) } @@ -316,6 +329,29 @@ fn returns_result(return_type: &ReturnType) -> bool { matches!(ty.as_ref(), Type::Path(path) if path.path.segments.last().is_some_and(|segment| segment.ident == "Result")) } +fn returns_slim_control_result(return_type: &ReturnType) -> bool { + let ReturnType::Type(_, ty) = return_type else { + return false; + }; + let Type::Path(path) = ty.as_ref() else { + return false; + }; + let Some(segment) = path.path.segments.last() else { + return false; + }; + let PathArguments::AngleBracketed(arguments) = &segment.arguments else { + return false; + }; + if segment.ident != "Result" { + return false; + } + matches!( + arguments.args.iter().nth(1), + Some(GenericArgument::Type(Type::Path(error))) + if error.path.segments.last().is_some_and(|segment| segment.ident == "SlimControlException") + ) +} + fn constructor_returns_fixture(return_type: &ReturnType, fixture_type: &Type) -> bool { let ReturnType::Type(_, output) = return_type else { return false; diff --git a/slim_protocol/src/lib.rs b/slim_protocol/src/lib.rs index 6e4c3b2..1ca06f4 100644 --- a/slim_protocol/src/lib.rs +++ b/slim_protocol/src/lib.rs @@ -59,6 +59,7 @@ where data: &[Instruction], ) -> Result, SendInstructionsError> { self.writer.write_all(data.to_slim_string().as_bytes())?; + self.writer.flush()?; Ok(Vec::from_reader(&mut self.reader)?) } @@ -332,9 +333,30 @@ pub enum ByeOrSlimInstructions { mod test { use std::error::Error; use std::io::Cursor; + use std::{ + cell::Cell, + io::{self, Write}, + rc::Rc, + }; use super::*; + #[derive(Clone)] + struct FlushWriter { + flushes: Rc>, + } + + impl Write for FlushWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.flushes.set(self.flushes.get() + 1); + Ok(()) + } + } + #[test] fn test_simple_connection() -> Result<(), Box> { let mut writer = Vec::new(); @@ -396,4 +418,29 @@ mod test { ); Ok(()) } + + #[test] + fn sending_instructions_flushes_before_waiting_for_a_response() -> Result<(), Box> { + let id = Id::from("id"); + let mut input = b"Slim -- V0.5\n".to_vec(); + input.extend_from_slice( + vec![InstructionResult::ok(id.clone())] + .to_slim_string() + .as_bytes(), + ); + let flushes = Rc::new(Cell::new(0)); + let mut connection = SlimConnection::new( + Cursor::new(input), + FlushWriter { + flushes: Rc::clone(&flushes), + }, + )?; + + assert_eq!( + vec![InstructionResult::ok(id)], + connection.send_instructions(&[])? + ); + assert_eq!(1, flushes.get()); + Ok(()) + } } diff --git a/temoc/src/slim_server_connector.rs b/temoc/src/slim_server_connector.rs index bbbece3..5262199 100644 --- a/temoc/src/slim_server_connector.rs +++ b/temoc/src/slim_server_connector.rs @@ -1,10 +1,10 @@ use crate::port::CyclePort; use anyhow::{anyhow, bail, Result}; use rand::Rng; -use std::io::{BufRead, BufReader, Read, Write}; +use std::io::{self, BufRead, BufReader, Read, Write}; use std::net::TcpStream; use std::process::{Child, Command, Stdio}; -use std::thread::sleep; +use std::thread::{sleep, JoinHandle}; use std::time::{Duration, Instant}; pub fn build_slim_server_connector( @@ -129,22 +129,30 @@ pub struct StdoutSlimServerConnector { struct StdoutSlimServer { child: Child, - pipe_output: bool, + stderr_thread: Option>>, } impl SlimServerConnector for StdoutSlimServerConnector { fn start_and_connect(&mut self) -> Result> { - let child = spawn_server( + let mut child = spawn_server( &self.command, 1, Stdio::piped(), Stdio::piped(), Stdio::piped(), )?; + let child_stderr = child + .stderr + .take() + .ok_or(anyhow!("Failed to open stderr"))?; + let pipe_output = self.pipe_output; + let stderr_thread = std::thread::spawn(move || { + drain_slim_stderr(child_stderr, pipe_output, io::stdout(), io::stderr()) + }); Ok(Box::new(StdoutSlimServer { child, - pipe_output: self.pipe_output, + stderr_thread: Some(stderr_thread), })) } } @@ -169,28 +177,83 @@ impl SlimServer for StdoutSlimServer { } fn close(&mut self) -> Result<()> { - self.child.wait()?; - if self.pipe_output { - let mut child_stderr = self - .child - .stderr - .take() - .ok_or(anyhow!("Failed to open stderr"))?; - let buff_read = BufReader::new(&mut child_stderr); - for line in buff_read.lines() { - let line = line?; - if let Some(line) = line.strip_prefix("SOUT :") { - println!("{}", line); - } else if let Some(line) = line.strip_prefix("SOUT.:") { - println!("{}", line); - } else if let Some(line) = line.strip_prefix("SERR :") { - eprintln!("{}", line); - } else if let Some(line) = line.strip_prefix("SERR.:") { - eprintln!("{}", line); - } - eprintln!("{}", line); - } - } + let wait_result = self.child.wait().map(|_| ()); + let drain_result = self + .stderr_thread + .take() + .ok_or(anyhow!("stderr drain thread is missing"))? + .join() + .map_err(|_| anyhow!("stderr drain thread panicked"))?; + wait_result?; + drain_result?; Ok(()) } } + +fn drain_slim_stderr( + reader: R, + pipe_output: bool, + mut stdout: O, + mut stderr: E, +) -> io::Result<()> +where + R: Read, + O: Write, + E: Write, +{ + for line in BufReader::new(reader).lines() { + let line = line?; + if !pipe_output { + continue; + } + if let Some(line) = line + .strip_prefix("SOUT :") + .or_else(|| line.strip_prefix("SOUT.:")) + { + writeln!(stdout, "{line}")?; + } else if let Some(line) = line + .strip_prefix("SERR :") + .or_else(|| line.strip_prefix("SERR.:")) + { + writeln!(stderr, "{line}")?; + } else { + writeln!(stdout, "{line}")?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::drain_slim_stderr; + use std::io::Cursor; + + #[test] + fn tunneled_stderr_is_split_without_duplicate_output() { + let input = b"SOUT :one\nSOUT.:two\nSERR :bad\nSERR.:worse\nplain\n"; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + drain_slim_stderr(Cursor::new(input), true, &mut stdout, &mut stderr).unwrap(); + + assert_eq!(b"one\ntwo\nplain\n", stdout.as_slice()); + assert_eq!(b"bad\nworse\n", stderr.as_slice()); + } + + #[test] + fn tunneled_stderr_is_still_drained_when_output_is_hidden() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + drain_slim_stderr( + Cursor::new(b"SOUT :ignored\nSERR :ignored\n"), + false, + &mut stdout, + &mut stderr, + ) + .unwrap(); + + assert!(stdout.is_empty()); + assert!(stderr.is_empty()); + } +}