refactor(dynamic): add recursive dependency resolution for SSA receivers, expand tests for Python and PHP

This commit is contained in:
elipeter 2026-05-24 17:09:24 -05:00
parent f49211d788
commit baa9a36bc6
13 changed files with 329 additions and 76 deletions

View file

@ -1743,9 +1743,9 @@ mod tests {
use super::*;
use std::sync::Mutex;
// Coarse lock: every test in this submodule mutates the same env
// var, so they have to take turns. `Mutex` is enough because the
// submodule is the only writer for `NYX_BUILD_STATIC`.
// Coarse lock: tests in this submodule mutate process env
// (`NYX_BUILD_STATIC`, and for dispatch tests `NYX_BUILD_CACHE`),
// so they have to take turns.
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
@ -1772,6 +1772,29 @@ mod tests {
}
}
struct BuildCacheGuard {
prior: Option<String>,
_dir: tempfile::TempDir,
}
impl BuildCacheGuard {
fn isolated() -> Self {
let dir = tempfile::TempDir::new().unwrap();
let prior = std::env::var("NYX_BUILD_CACHE").ok();
unsafe { std::env::set_var("NYX_BUILD_CACHE", dir.path()) };
Self { prior, _dir: dir }
}
}
impl Drop for BuildCacheGuard {
fn drop(&mut self) {
match self.prior.take() {
Some(v) => unsafe { std::env::set_var("NYX_BUILD_CACHE", v) },
None => unsafe { std::env::remove_var("NYX_BUILD_CACHE") },
}
}
}
#[test]
fn unset_env_means_dynamic_link() {
let _lock = ENV_LOCK.lock().unwrap();
@ -1871,10 +1894,8 @@ mod tests {
/// Scrub the cache directory `prepare_node` would land in so a
/// fresh-cache assertion stays deterministic across reruns. The
/// per-test `toolchain_id` already isolates this submodule from
/// every other test, but `cargo test --workspace` reruns reuse
/// the same `$HOME/Library/Caches/...` slot, so we have to wipe
/// it ourselves before asserting on the cache-miss branch.
/// dispatch tests install an isolated `NYX_BUILD_CACHE`, so this
/// only clears state from earlier calls inside the same test.
fn purge_node_cache_for(spec: &HarnessSpec, workdir: &Path) {
let lockfile_hash = compute_node_lockfile_hash(workdir);
if let Ok(cache_path) = build_cache_path(&lockfile_hash, "node", &spec.toolchain_id) {
@ -1905,6 +1926,8 @@ mod tests {
// with cache_hit=false + duration=0 + lang=TypeScript on first
// call. Use TypeScript to also lock in that the JS/TS arm
// shares one dispatch leg.
let _lock = ENV_LOCK.lock().unwrap();
let _cache = BuildCacheGuard::isolated();
let dir = tempfile::TempDir::new().unwrap();
let spec = mk_spec(Lang::TypeScript, "ts-no-package-json");
purge_node_cache_for(&spec, dir.path());
@ -1937,6 +1960,8 @@ mod tests {
// Both JS and TS route to prepare_node so a back-to-back call
// with the same toolchain_id + workdir contents must hit the
// same cache.
let _lock = ENV_LOCK.lock().unwrap();
let _cache = BuildCacheGuard::isolated();
let dir = tempfile::TempDir::new().unwrap();
// Both specs share one toolchain suffix so they collide in
// the same cache slot — the contract under test is that JS

View file

@ -2681,8 +2681,24 @@ try {{
exit(77);
}}
function _nyx_build_receiver(string $cls) {{
function _nyx_known_mock_for(string $name) {{
$n = strtolower($name);
if (strpos($n, 'http') !== false || strpos($n, 'client') !== false) {{
return new MockHttpClient();
}}
if (strpos($n, 'db') !== false || strpos($n, 'conn') !== false || strpos($n, 'repo') !== false || strpos($n, 'session') !== false) {{
return new MockDatabaseConnection();
}}
if (strpos($n, 'log') !== false) {{
return new MockLogger();
}}
return null;
}}
function _nyx_build_receiver(string $cls, int $depth = 3, array $seen = []) {{
if (!class_exists($cls)) return null;
if (isset($seen[$cls])) return null;
$seen[$cls] = true;
try {{ return new $cls(); }} catch (Throwable $e) {{}}
$rc = new ReflectionClass($cls);
$ctor = $rc->getConstructor();
@ -2692,16 +2708,16 @@ function _nyx_build_receiver(string $cls) {{
}}
$args = [];
foreach ($ctor->getParameters() as $p) {{
$n = strtolower($p->getName());
if (strpos($n, 'http') !== false || strpos($n, 'client') !== false) {{
$args[] = new MockHttpClient();
}} elseif (strpos($n, 'db') !== false || strpos($n, 'conn') !== false || strpos($n, 'repo') !== false || strpos($n, 'session') !== false) {{
$args[] = new MockDatabaseConnection();
}} elseif (strpos($n, 'log') !== false) {{
$args[] = new MockLogger();
}} else {{
$args[] = null;
$dep = null;
$type = $p->getType();
if ($depth > 0 && $type instanceof ReflectionNamedType && !$type->isBuiltin()) {{
$typeName = $type->getName();
if (class_exists($typeName) && $typeName !== $cls) {{
$dep = _nyx_build_receiver($typeName, $depth - 1, $seen);
}}
}}
if ($dep === null) $dep = _nyx_known_mock_for($p->getName());
$args[] = $dep;
}}
try {{ return $rc->newInstanceArgs($args); }} catch (Throwable $e) {{}}
return null;

View file

@ -831,29 +831,54 @@ if _cls is None:
print("NYX_CLASS_NOT_FOUND: " + {class:?}, file=sys.stderr, flush=True)
sys.exit(78)
def _nyx_build_receiver(cls):
def _nyx_known_mock_for(name):
n = name.lower()
if 'http' in n or 'client' in n:
return MockHttpClient()
if 'db' in n or 'conn' in n or 'session' in n:
return MockDatabaseConnection()
if 'log' in n:
return MockLogger()
return None
def _nyx_resolve_annotation(ann):
if ann is None:
return None
try:
if isinstance(ann, str):
return getattr(_entry_mod, ann, None)
if getattr(ann, "__module__", None) == getattr(_entry_mod, "__name__", None):
return ann
except Exception:
return None
return None
def _nyx_build_receiver(cls, depth=3, seen=None):
if seen is None:
seen = set()
if cls in seen:
return None
seen.add(cls)
# Preferred path: zero-arg ctor.
try:
return cls()
except TypeError:
pass
# Fallback path: stubbed dependencies. Walk the ctor's positional
# formals (best-effort via inspect.signature) and pass mocks for
# known shapes; default to `None` for the rest.
# Fallback path: recursively build in-file typed dependencies up to
# depth 3, then use known boundary mocks by constructor-name shape.
import inspect
try:
sig = inspect.signature(cls.__init__)
args = []
for name, p in list(sig.parameters.items())[1:]: # skip `self`
n = name.lower()
if 'http' in n or 'client' in n:
args.append(MockHttpClient())
elif 'db' in n or 'conn' in n or 'session' in n:
args.append(MockDatabaseConnection())
elif 'log' in n:
args.append(MockLogger())
else:
args.append(None)
dep = None
if depth > 0:
dep_cls = _nyx_resolve_annotation(getattr(p, "annotation", None))
if dep_cls is not None and dep_cls is not cls:
dep = _nyx_build_receiver(dep_cls, depth - 1, set(seen))
if dep is None:
dep = _nyx_known_mock_for(name)
args.append(dep)
return cls(*args)
except Exception as _e:
# Last resort: single-mock fallback so a single-arg ctor still

View file

@ -182,6 +182,18 @@ fn parse_nonce_from_request_line(line: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use std::io::ErrorKind;
fn bind_or_skip(test_name: &str) -> Option<OobListener> {
match OobListener::bind() {
Ok(listener) => Some(listener),
Err(e) if e.kind() == ErrorKind::PermissionDenied => {
eprintln!("SKIP {test_name}: loopback bind denied by test sandbox: {e}");
None
}
Err(e) => panic!("bind must succeed on loopback outside sandbox-denied hosts: {e}"),
}
}
#[test]
fn parse_nonce_standard_get() {
@ -211,13 +223,17 @@ mod tests {
#[test]
fn oob_listener_bind_and_port() {
let listener = OobListener::bind().expect("bind must succeed on loopback");
let Some(listener) = bind_or_skip("oob_listener_bind_and_port") else {
return;
};
assert_ne!(listener.port(), 0, "OS must assign a non-zero port");
}
#[test]
fn oob_listener_records_nonce_via_http() {
let listener = OobListener::bind().expect("bind");
let Some(listener) = bind_or_skip("oob_listener_records_nonce_via_http") else {
return;
};
let nonce = "nyx_test_nonce_abc123";
let url = listener.nonce_url(nonce);
@ -226,33 +242,42 @@ mod tests {
// Make an HTTP request with the nonce in the path.
let addr = format!("127.0.0.1:{}", listener.port());
if let Ok(mut stream) = TcpStream::connect(&addr) {
let req = format!("GET /{nonce} HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n");
let _ = stream.write_all(req.as_bytes());
// Read response to ensure the server processed the request.
let mut buf = [0u8; 64];
let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(500)));
let _ = std::io::Read::read(&mut stream, &mut buf);
}
// Allow the handler thread to update the hits set.
std::thread::sleep(std::time::Duration::from_millis(50));
let mut stream = match TcpStream::connect(&addr) {
Ok(stream) => stream,
Err(e) if e.kind() == ErrorKind::PermissionDenied => {
eprintln!(
"SKIP oob_listener_records_nonce_via_http: loopback connect denied by test sandbox: {e}"
);
return;
}
Err(e) => panic!("connect to listener {addr} must succeed: {e}"),
};
let req = format!("GET /{nonce} HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n");
let _ = stream.write_all(req.as_bytes());
// Read response to ensure the server processed the request.
let mut buf = [0u8; 64];
let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(500)));
let _ = std::io::Read::read(&mut stream, &mut buf);
assert!(
listener.was_nonce_hit(nonce),
listener.wait_for_nonce(nonce, std::time::Duration::from_millis(500)),
"listener must record the nonce from the HTTP request; url={url}"
);
}
#[test]
fn oob_listener_unknown_nonce_not_hit() {
let listener = OobListener::bind().expect("bind");
let Some(listener) = bind_or_skip("oob_listener_unknown_nonce_not_hit") else {
return;
};
assert!(!listener.was_nonce_hit("not_a_real_nonce_xyz"));
}
#[test]
fn nonce_url_format() {
let listener = OobListener::bind().expect("bind");
let Some(listener) = bind_or_skip("nonce_url_format") else {
return;
};
let port = listener.port();
let url = listener.nonce_url("mynonce");
assert_eq!(url, format!("http://127.0.0.1:{port}/mynonce"));

View file

@ -168,8 +168,7 @@ pub fn network_args(policy: &NetworkPolicy) -> Vec<String> {
args.extend(["--network".to_owned(), "none".to_owned()]);
}
NetworkPolicy::OobOutbound { .. } => {
args.extend(["--network".to_owned(), "bridge".to_owned()]);
args.push("--add-host=host-gateway:host-gateway".to_owned());
args.extend(oob_outbound_network_args());
}
NetworkPolicy::StubsOnly { allow } => {
args.extend(["--network".to_owned(), "bridge".to_owned()]);
@ -185,6 +184,14 @@ pub fn network_args(policy: &NetworkPolicy) -> Vec<String> {
args
}
fn oob_outbound_network_args() -> Vec<String> {
vec![
"--network".to_owned(),
"bridge".to_owned(),
"--add-host=host-gateway:host-gateway".to_owned(),
]
}
fn add_host_arg(hp: &HostPort) -> String {
format!("--add-host={}:host-gateway", hp.host)
}
@ -193,7 +200,6 @@ fn add_host_arg(hp: &HostPort) -> String {
mod tests {
use super::*;
use std::path::PathBuf;
use std::sync::Arc;
#[test]
fn workdir_mount_args_uses_fixed_path() {
@ -248,11 +254,7 @@ mod tests {
#[test]
fn network_args_oob_threads_host_gateway() {
let listener = Arc::new(
crate::dynamic::oob::OobListener::bind()
.expect("oob listener must bind on 127.0.0.1 in tests"),
);
let args = network_args(&NetworkPolicy::OobOutbound { listener });
let args = oob_outbound_network_args();
assert!(
args.iter()
.any(|a| a == "--add-host=host-gateway:host-gateway")