#![cfg(feature = "ipc")]
use std::sync::Arc;
use std::time::Duration;
use acton_reactive::ipc::{
start_listener, IpcClient, IpcConfig, IpcEnvelope, IpcError, IpcLimitsConfig, IpcTypeRegistry,
SocketConfig,
};
use dashmap::DashMap;
use tokio_util::sync::CancellationToken;
fn test_config(socket_path: std::path::PathBuf, max_connections: usize) -> IpcConfig {
IpcConfig {
socket: SocketConfig {
path: Some(socket_path),
..SocketConfig::default()
},
limits: IpcLimitsConfig {
max_connections,
..IpcLimitsConfig::default()
},
..IpcConfig::default()
}
}
async fn start_test_listener(
config: IpcConfig,
) -> (acton_reactive::ipc::IpcListenerHandle, CancellationToken) {
let cancel = CancellationToken::new();
let handle = start_listener(
config,
Arc::new(IpcTypeRegistry::new()),
Arc::new(DashMap::new()),
cancel.clone(),
)
.await
.expect("listener should start");
(handle, cancel)
}
fn probe_request() -> IpcEnvelope {
IpcEnvelope::new("no_such_actor", "NoSuchMessage", serde_json::json!({}))
}
async fn connect_and_occupy_a_permit(
socket: &std::path::Path,
stats: &acton_reactive::ipc::IpcListenerStats,
expected_active: usize,
) -> IpcClient {
let client = IpcClient::connect(socket).await.expect("connect");
client
.send(probe_request())
.await
.expect("fire-and-forget send");
for _ in 0..200 {
if stats.connections_active() >= expected_active {
return client;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("server never registered {expected_active} active connection(s)");
}
#[tokio::test]
async fn a_connection_refused_at_the_limit_reports_the_limit_not_a_broken_pipe() {
let dir = tempfile::tempdir().expect("tempdir");
let socket = dir.path().join("ipc.sock");
let (handle, cancel) = start_test_listener(test_config(socket.clone(), 1)).await;
let _holder = connect_and_occupy_a_permit(&socket, &handle.stats, 1).await;
let refused = IpcClient::connect(&socket)
.await
.expect("connect succeeds; the refusal comes after");
let error = refused
.request(probe_request())
.await
.expect_err("the server refused this connection");
assert!(
matches!(error, IpcError::ConnectionLimitReached { limit: 1 }),
"expected ConnectionLimitReached {{ limit: 1 }}, got {error:?} ({error})"
);
let rendered = error.to_string();
assert!(
rendered.contains("connection limit"),
"error message should name the connection limit, got: {rendered}"
);
cancel.cancel();
drop(handle);
}
#[tokio::test]
async fn a_refused_connection_exposes_its_rejection_reason() {
let dir = tempfile::tempdir().expect("tempdir");
let socket = dir.path().join("ipc.sock");
let (handle, cancel) = start_test_listener(test_config(socket.clone(), 1)).await;
let holder = connect_and_occupy_a_permit(&socket, &handle.stats, 1).await;
let refused = IpcClient::connect(&socket).await.expect("connect");
let _ = refused.request(probe_request()).await;
assert!(
matches!(
refused.rejection_reason(),
Some(IpcError::ConnectionLimitReached { limit: 1 })
),
"rejection_reason should report the limit, got {:?}",
refused.rejection_reason()
);
assert!(
holder.rejection_reason().is_none(),
"an accepted connection has no rejection reason"
);
cancel.cancel();
drop(handle);
}
#[tokio::test]
async fn a_connection_within_the_limit_is_not_refused() {
let dir = tempfile::tempdir().expect("tempdir");
let socket = dir.path().join("ipc.sock");
let (handle, cancel) = start_test_listener(test_config(socket.clone(), 4)).await;
let client = IpcClient::connect(&socket).await.expect("connect");
let outcome = client
.request_with_timeout(probe_request(), Duration::from_millis(500))
.await;
if let Err(error) = outcome {
assert!(
!matches!(error, IpcError::ConnectionLimitReached { .. }),
"a connection within the limit must not be refused, got {error:?}"
);
}
assert!(
client.rejection_reason().is_none(),
"a connection within the limit records no refusal"
);
cancel.cancel();
drop(handle);
}
#[tokio::test]
async fn stats_report_the_connection_count_against_the_limit() {
let dir = tempfile::tempdir().expect("tempdir");
let socket = dir.path().join("ipc.sock");
let (handle, cancel) = start_test_listener(test_config(socket.clone(), 3)).await;
assert_eq!(handle.stats.max_connections(), 3);
assert_eq!(handle.stats.connections_active(), 0);
assert_eq!(handle.stats.connections_available(), 3);
let _client = connect_and_occupy_a_permit(&socket, &handle.stats, 1).await;
assert_eq!(handle.stats.max_connections(), 3);
assert_eq!(handle.stats.connections_active(), 1);
assert_eq!(
handle.stats.connections_available(),
2,
"one of three permits is in use"
);
cancel.cancel();
drop(handle);
}
#[test]
fn available_connections_saturate_at_zero() {
let stats = acton_reactive::ipc::IpcListenerStats::with_max_connections(0);
assert_eq!(stats.max_connections(), 0);
assert_eq!(stats.connections_available(), 0);
}
#[test]
fn the_default_connection_limit_is_raised_above_the_value_that_broke_emergent() {
assert_eq!(IpcLimitsConfig::default().max_connections, 1024);
}
#[test]
fn a_rejection_is_wire_compatible_with_a_client_that_does_not_know_the_variant() {
let response = acton_reactive::ipc::IpcResponse::connection_rejected(100);
let json = serde_json::to_string(&response).expect("serialize");
let decoded: serde_json::Value = serde_json::from_str(&json).expect("deserialize");
assert_eq!(decoded["success"], serde_json::json!(false));
assert_eq!(
decoded["error_code"],
serde_json::json!(acton_reactive::ipc::CONNECTION_LIMIT_REACHED_CODE)
);
assert_eq!(
decoded["correlation_id"],
serde_json::json!(acton_reactive::ipc::CONNECTION_REJECTED_CORRELATION_ID)
);
assert_eq!(decoded["payload"]["limit"], serde_json::json!(100));
let reparsed: acton_reactive::ipc::IpcResponse =
serde_json::from_str(&json).expect("round-trip");
assert!(matches!(
reparsed.as_connection_rejection(),
Some(IpcError::ConnectionLimitReached { limit: 100 })
));
}
#[test]
fn a_normal_response_is_not_a_rejection() {
let ok = acton_reactive::ipc::IpcResponse::success("req_123", None);
assert!(ok.as_connection_rejection().is_none());
let err = acton_reactive::ipc::IpcResponse::error("req_123", &IpcError::TargetBusy);
assert!(err.as_connection_rejection().is_none());
}
#[test]
fn an_unknown_rejection_reason_is_surfaced_rather_than_dropped() {
let response = acton_reactive::ipc::IpcResponse::error_with_message(
acton_reactive::ipc::CONNECTION_REJECTED_CORRELATION_ID,
"SOME_FUTURE_REFUSAL",
"refused for a reason from the future",
);
match response.as_connection_rejection() {
Some(IpcError::ProtocolError(message)) => {
assert!(message.contains("from the future"), "got: {message}");
}
other => panic!("expected the refusal to survive, got {other:?}"),
}
}
fn write_config(path: &std::path::Path, max_connections: usize) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create config dir");
}
std::fs::write(
path,
format!("[limits]\nmax_connections = {max_connections}\n"),
)
.expect("write config");
}
#[test]
fn the_loader_finds_a_per_app_config() {
let dir = tempfile::tempdir().expect("tempdir");
write_config(&dir.path().join("my_app").join("ipc.toml"), 7);
let config = IpcConfig::load_from_root(dir.path(), "my_app");
assert_eq!(config.limits.max_connections, 7);
}
#[test]
fn the_loader_falls_back_to_the_shared_config() {
let dir = tempfile::tempdir().expect("tempdir");
write_config(&dir.path().join("ipc.toml"), 11);
let config = IpcConfig::load_from_root(dir.path(), "my_app");
assert_eq!(config.limits.max_connections, 11);
}
#[test]
fn a_per_app_config_takes_precedence_over_the_shared_one() {
let dir = tempfile::tempdir().expect("tempdir");
write_config(&dir.path().join("ipc.toml"), 11);
write_config(&dir.path().join("my_app").join("ipc.toml"), 7);
let config = IpcConfig::load_from_root(dir.path(), "my_app");
assert_eq!(
config.limits.max_connections, 7,
"the per-app file must win over the shared one"
);
}
#[test]
fn another_apps_config_is_not_used() {
let dir = tempfile::tempdir().expect("tempdir");
write_config(&dir.path().join("other_app").join("ipc.toml"), 7);
let config = IpcConfig::load_from_root(dir.path(), "my_app");
assert_eq!(
config.limits.max_connections,
IpcLimitsConfig::default().max_connections,
"only this app's file or the shared file may apply"
);
}
#[test]
fn no_config_file_yields_defaults() {
let dir = tempfile::tempdir().expect("tempdir");
let config = IpcConfig::load_from_root(dir.path(), "my_app");
assert_eq!(
config.limits.max_connections,
IpcLimitsConfig::default().max_connections
);
}
#[tokio::test]
async fn an_idle_connection_records_no_rejection() {
let dir = tempfile::tempdir().expect("tempdir");
let socket = dir.path().join("ipc.sock");
let (handle, cancel) = start_test_listener(test_config(socket.clone(), 2)).await;
let client = IpcClient::connect(&socket).await.expect("connect");
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(client.rejection_reason().is_none());
cancel.cancel();
drop(handle);
}