refactor(dynamic): improve fallback handling for sandbox restrictions, centralize and enhance stub initialization, and expand test coverage across harnesses

This commit is contained in:
elipeter 2026-05-25 12:46:53 -05:00
parent cb3b39d892
commit 68bdd30eca
17 changed files with 546 additions and 68 deletions

View file

@ -68,16 +68,26 @@ impl HttpStub {
/// back to the process-wide temp directory when `workdir` is not
/// writable.
pub fn start(workdir: &Path) -> std::io::Result<Self> {
let listener = TcpListener::bind("127.0.0.1:0")?;
listener.set_nonblocking(false)?;
let port = listener.local_addr()?.port();
let events: Arc<Mutex<Vec<StubEvent>>> = Arc::new(Mutex::new(Vec::new()));
let shutdown = Arc::new(AtomicBool::new(false));
let events_clone = Arc::clone(&events);
let shutdown_clone = Arc::clone(&shutdown);
std::thread::spawn(move || accept_loop(listener, events_clone, shutdown_clone));
let port = match TcpListener::bind("127.0.0.1:0") {
Ok(listener) => {
listener.set_nonblocking(false)?;
let port = listener.local_addr()?.port();
let events_clone = Arc::clone(&events);
let shutdown_clone = Arc::clone(&shutdown);
std::thread::spawn(move || accept_loop(listener, events_clone, shutdown_clone));
port
}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
// Some host sandboxes deny loopback binds. Keep the
// side-channel recorder alive so generated shims can
// still surface attempted outbound calls deterministically.
0
}
Err(e) => return Err(e),
};
let tempdir = TempDir::new_in(workdir).or_else(|_| TempDir::new())?;
let log_path = tempdir.path().join("nyx_http_stub.requests.log");
@ -343,6 +353,9 @@ mod tests {
let Some((_dir, stub)) = start_stub() else {
return;
};
if stub.port() == 0 {
return;
}
let reply = send_request(
stub.port(),
b"GET /api/users HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
@ -364,6 +377,9 @@ mod tests {
let Some((_dir, stub)) = start_stub() else {
return;
};
if stub.port() == 0 {
return;
}
let body = b"username=admin&password=hunter2";
let req = format!(
"POST /login HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: {}\r\n\r\n",

View file

@ -1,5 +1,5 @@
//! Phase 19 (Track M.1) — language-specific mock generators for class
//! constructor parameters.
//! Runtime and source-level mock providers for class constructor
//! parameters.
//!
//! When [`crate::dynamic::lang::LangEmitter::emit`] hits an
//! `EntryKind::ClassMethod` whose constructor takes an injectable
@ -10,14 +10,20 @@
//! the real type but performs no I/O.
//!
//! The registry is deliberately small: only the three dependency
//! shapes mentioned in Phase 19's brief
//! shapes currently emitted by the class-method harness
//! (`MockHttpClient`, `MockDatabaseConnection`, `MockLogger`) are
//! covered. A future phase that needs richer doubles
//! covered. A future phase that needs richer doubles
//! (`MockCache`, `MockSessionStore`, …) can extend the [`MockKind`]
//! enum + add new branches to [`mock_source`] without re-versioning the
//! caller surface.
//! enum, add new branches to [`mock_source`], and register the runtime
//! provider without re-versioning the caller surface.
use super::{StubEvent, StubKind, StubProvider, monotonic_ns};
use crate::symbol::Lang;
use std::fs::OpenOptions;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tempfile::TempDir;
/// Discriminator for an injectable dependency the harness may need to
/// stub when constructing a class receiver.
@ -51,6 +57,169 @@ impl MockKind {
Self::Logger => "MockLogger",
}
}
/// Runtime stub discriminator for this mock kind.
pub const fn stub_kind(self) -> StubKind {
match self {
Self::HttpClient => StubKind::MockHttpClient,
Self::DatabaseConnection => StubKind::MockDatabaseConnection,
Self::Logger => StubKind::MockLogger,
}
}
/// Stable lower-case tag used for filenames and event details.
pub const fn tag(self) -> &'static str {
match self {
Self::HttpClient => "http_client",
Self::DatabaseConnection => "database_connection",
Self::Logger => "logger",
}
}
/// Companion env var where harness-side mock shims append calls.
pub const fn log_env_var(self) -> &'static str {
match self {
Self::HttpClient => "NYX_MOCK_HTTP_CLIENT_LOG",
Self::DatabaseConnection => "NYX_MOCK_DATABASE_CONNECTION_LOG",
Self::Logger => "NYX_MOCK_LOGGER_LOG",
}
}
/// Convert a runtime stub kind back into a mock kind.
pub const fn from_stub_kind(kind: StubKind) -> Option<Self> {
match kind {
StubKind::MockHttpClient => Some(Self::HttpClient),
StubKind::MockDatabaseConnection => Some(Self::DatabaseConnection),
StubKind::MockLogger => Some(Self::Logger),
_ => None,
}
}
}
/// Runtime mock provider.
///
/// The endpoint is a stable logical name rather than a socket address:
/// harnesses still construct in-process test doubles, but those doubles
/// can append one line per method call to [`Self::log_path`]. That gives
/// the verifier the same `StubProvider` lifecycle and event-drain
/// surface used by SQL / HTTP / LDAP stubs without requiring a network
/// service for no-op mocks.
#[derive(Debug)]
pub struct MockStub {
kind: MockKind,
tempdir: Option<TempDir>,
log_path: PathBuf,
cursor: Mutex<u64>,
}
impl MockStub {
/// Start a mock provider rooted under `workdir`.
pub fn start(kind: MockKind, workdir: &Path) -> std::io::Result<Self> {
let tempdir = TempDir::new_in(workdir).or_else(|_| TempDir::new())?;
let log_path = tempdir.path().join(format!("nyx_mock_{}.log", kind.tag()));
std::fs::File::create(&log_path)?;
Ok(Self {
kind,
tempdir: Some(tempdir),
log_path,
cursor: Mutex::new(0),
})
}
/// Mock dependency kind this provider represents.
pub const fn mock_kind(&self) -> MockKind {
self.kind
}
/// Absolute path of the side-channel call log.
pub fn log_path(&self) -> &Path {
&self.log_path
}
/// Host-side helper for tests and future adapters.
pub fn record_call(&self, method: &str, detail: &str) -> std::io::Result<()> {
let mut f = OpenOptions::new()
.append(true)
.create(true)
.open(&self.log_path)?;
if detail.is_empty() {
writeln!(f, "{method}")?;
} else {
writeln!(f, "{method}\t{detail}")?;
}
Ok(())
}
}
impl StubProvider for MockStub {
fn kind(&self) -> StubKind {
self.kind.stub_kind()
}
fn endpoint(&self) -> String {
self.kind.type_name().to_owned()
}
fn recording_endpoint(&self) -> Option<(&'static str, String)> {
Some((
self.kind.log_env_var(),
self.log_path.to_string_lossy().into_owned(),
))
}
fn drain_events(&self) -> Vec<StubEvent> {
let mut cursor = match self.cursor.lock() {
Ok(g) => g,
Err(_) => return Vec::new(),
};
let file = match std::fs::File::open(&self.log_path) {
Ok(f) => f,
Err(_) => return Vec::new(),
};
use std::io::Seek;
let mut reader = BufReader::new(file);
if reader.seek(std::io::SeekFrom::Start(*cursor)).is_err() {
return Vec::new();
}
let mut events = Vec::new();
let mut bytes_read: u64 = 0;
let mut buf = String::new();
loop {
buf.clear();
let n = match reader.read_line(&mut buf) {
Ok(0) => break,
Ok(n) => n,
Err(_) => break,
};
bytes_read += n as u64;
let line = buf.trim_end_matches(['\r', '\n']);
if line.is_empty() {
continue;
}
let (method, detail) = line.split_once('\t').unwrap_or((line, ""));
let mut ev = StubEvent::new(
self.kind.stub_kind(),
format!("{} {}", self.kind.type_name(), method),
)
.with_detail("mock", self.kind.tag())
.with_detail("method", method);
if !detail.is_empty() {
ev = ev.with_detail("detail", detail);
}
ev.captured_at_ns = monotonic_ns();
events.push(ev);
}
*cursor += bytes_read;
events
}
}
impl Drop for MockStub {
fn drop(&mut self) {
self.tempdir.take();
}
}
/// Source snippet declaring a `MockKind` test double in `lang`.
@ -180,6 +349,40 @@ mod tests {
assert_eq!(MockKind::Logger.type_name(), "MockLogger");
}
#[test]
fn mock_kind_maps_to_runtime_stub_kind() {
assert_eq!(MockKind::HttpClient.stub_kind(), StubKind::MockHttpClient);
assert_eq!(
MockKind::from_stub_kind(StubKind::MockDatabaseConnection),
Some(MockKind::DatabaseConnection)
);
assert_eq!(MockKind::from_stub_kind(StubKind::Sql), None);
}
#[test]
fn mock_stub_records_calls_as_stub_events() {
let dir = TempDir::new().unwrap();
let stub = MockStub::start(MockKind::HttpClient, dir.path()).unwrap();
assert_eq!(stub.kind(), StubKind::MockHttpClient);
assert_eq!(stub.endpoint(), "MockHttpClient");
let recording = stub.recording_endpoint().expect("mock log path");
assert_eq!(recording.0, "NYX_MOCK_HTTP_CLIENT_LOG");
assert!(recording.1.ends_with("nyx_mock_http_client.log"));
stub.record_call("get", "http://example.test/users")
.unwrap();
let events = stub.drain_events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, StubKind::MockHttpClient);
assert!(events[0].summary.contains("MockHttpClient get"));
assert_eq!(events[0].detail.get("method").unwrap(), "get");
assert_eq!(
events[0].detail.get("detail").unwrap(),
"http://example.test/users"
);
assert!(stub.drain_events().is_empty());
}
#[test]
fn mock_source_python_declares_class() {
let src = mock_source(MockKind::HttpClient, Lang::Python);

View file

@ -73,7 +73,7 @@ pub use broker_sqs::{SQS_PUBLISH_MARKER, sqs_source};
pub use filesystem::FilesystemStub;
pub use http::HttpStub;
pub use ldap_server::LdapStub;
pub use mocks::{MockKind, mock_source};
pub use mocks::{MockKind, MockStub, mock_source};
pub use redis::RedisStub;
pub use sql::SqlStub;
@ -104,6 +104,13 @@ pub enum StubKind {
/// one-liner documented in
/// [`crate::dynamic::stubs::ldap_server`].
Ldap,
/// Runtime provider for an injectable HTTP-client test double.
MockHttpClient,
/// Runtime provider for an injectable database-connection test
/// double.
MockDatabaseConnection,
/// Runtime provider for an injectable logger test double.
MockLogger,
}
impl StubKind {
@ -118,6 +125,9 @@ impl StubKind {
StubKind::Redis => "NYX_REDIS_ENDPOINT",
StubKind::Filesystem => "NYX_FS_ROOT",
StubKind::Ldap => ldap_server::LDAP_ENDPOINT_ENV_VAR,
StubKind::MockHttpClient => "NYX_MOCK_HTTP_CLIENT_ENDPOINT",
StubKind::MockDatabaseConnection => "NYX_MOCK_DATABASE_CONNECTION_ENDPOINT",
StubKind::MockLogger => "NYX_MOCK_LOGGER_ENDPOINT",
}
}
@ -131,6 +141,9 @@ impl StubKind {
StubKind::Redis => "redis",
StubKind::Filesystem => "filesystem",
StubKind::Ldap => "ldap",
StubKind::MockHttpClient => "mock_http_client",
StubKind::MockDatabaseConnection => "mock_database_connection",
StubKind::MockLogger => "mock_logger",
}
}
@ -271,6 +284,13 @@ impl StubHarness {
StubKind::Redis => Arc::new(RedisStub::start()?),
StubKind::Filesystem => Arc::new(FilesystemStub::start(workdir)?),
StubKind::Ldap => Arc::new(LdapStub::start()?),
StubKind::MockHttpClient => {
Arc::new(MockStub::start(MockKind::HttpClient, workdir)?)
}
StubKind::MockDatabaseConnection => {
Arc::new(MockStub::start(MockKind::DatabaseConnection, workdir)?)
}
StubKind::MockLogger => Arc::new(MockStub::start(MockKind::Logger, workdir)?),
};
stubs.push(stub);
}
@ -350,6 +370,10 @@ mod tests {
StubKind::Http,
StubKind::Redis,
StubKind::Filesystem,
StubKind::Ldap,
StubKind::MockHttpClient,
StubKind::MockDatabaseConnection,
StubKind::MockLogger,
]
.iter()
.map(|k| k.env_var())
@ -416,10 +440,20 @@ mod tests {
#[test]
fn endpoints_carries_stub_specific_env_var_names() {
let dir = TempDir::new().unwrap();
let h = StubHarness::start(&[StubKind::Sql, StubKind::Filesystem], dir.path()).unwrap();
let h = StubHarness::start(
&[
StubKind::Sql,
StubKind::Filesystem,
StubKind::MockHttpClient,
],
dir.path(),
)
.unwrap();
let names: Vec<&str> = h.endpoints().iter().map(|(n, _)| *n).collect();
assert!(names.contains(&"NYX_SQL_ENDPOINT"));
assert!(names.contains(&"NYX_FS_ROOT"));
assert!(names.contains(&"NYX_MOCK_HTTP_CLIENT_ENDPOINT"));
assert!(names.contains(&"NYX_MOCK_HTTP_CLIENT_LOG"));
assert_eq!(StubKind::Http.env_var(), "NYX_HTTP_ENDPOINT");
}

View file

@ -36,15 +36,25 @@ pub struct RedisStub {
impl RedisStub {
/// Bind to a random loopback port and start accepting connections.
pub fn start() -> std::io::Result<Self> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let events: Arc<Mutex<Vec<StubEvent>>> = Arc::new(Mutex::new(Vec::new()));
let shutdown = Arc::new(AtomicBool::new(false));
let events_clone = Arc::clone(&events);
let shutdown_clone = Arc::clone(&shutdown);
std::thread::spawn(move || accept_loop(listener, events_clone, shutdown_clone));
let port = match TcpListener::bind("127.0.0.1:0") {
Ok(listener) => {
let port = listener.local_addr()?.port();
let events_clone = Arc::clone(&events);
let shutdown_clone = Arc::clone(&shutdown);
std::thread::spawn(move || accept_loop(listener, events_clone, shutdown_clone));
port
}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
// Keep host-side recording usable under loopback-denying
// sandboxes. Tests and generated shims that only use the
// event channel do not need a live socket.
0
}
Err(e) => return Err(e),
};
Ok(Self {
port,
@ -249,6 +259,9 @@ mod tests {
let Some(stub) = start_stub() else {
return;
};
if stub.port() == 0 {
return;
}
let mut s = TcpStream::connect(format!("127.0.0.1:{}", stub.port())).unwrap();
s.write_all(b"SET user:1 alice\r\n").unwrap();
s.flush().unwrap();
@ -269,6 +282,9 @@ mod tests {
let Some(stub) = start_stub() else {
return;
};
if stub.port() == 0 {
return;
}
let mut s = TcpStream::connect(format!("127.0.0.1:{}", stub.port())).unwrap();
// `GET sessions`
s.write_all(b"*2\r\n$3\r\nGET\r\n$8\r\nsessions\r\n")