pub mod bridge;
pub mod config;
pub mod context;
mod ffi;
pub mod pool;
use std::ffi::CString;
use std::sync::Arc;
pub use config::PhpConfig;
pub use pool::{PhpPool, PhpPoolStats, PhpResponse};
pub struct PhpState {
pool: Option<Arc<PhpPool>>,
config: PhpConfig,
}
impl PhpState {
pub fn init(config: PhpConfig) -> anyhow::Result<Self> {
if !config.enabled {
tracing::info!("PHP runtime disabled by configuration");
return Ok(Self { pool: None, config });
}
let doc_root = std::path::Path::new(&config.document_root);
if !doc_root.is_dir() {
tracing::warn!(
path = %config.document_root,
"PHP document_root does not exist; PHP requests will return 404"
);
}
let ini_str = config.ini_entries_string();
let c_ini =
CString::new(ini_str).map_err(|e| anyhow::anyhow!("Invalid INI string: {}", e))?;
let ret = unsafe { ffi::bext_php_module_init(c_ini.as_ptr()) };
if ret != 0 {
return Err(anyhow::anyhow!(
"PHP module initialization failed (bext_php_module_init returned {})",
ret
));
}
let worker_count = config.effective_workers();
let mode_label = if config.is_worker_mode() {
"worker"
} else {
"classic"
};
tracing::info!(
workers = worker_count,
mode = mode_label,
document_root = %config.document_root,
"PHP runtime initialized"
);
let pool = if config.is_worker_mode() {
let script = config.worker_script.as_ref().unwrap().clone();
PhpPool::worker(worker_count, script, config.max_requests)
} else {
PhpPool::with_max_requests(worker_count, config.max_requests)
}
.map_err(|e| anyhow::anyhow!("Failed to create PHP pool: {}", e))?;
Ok(Self {
pool: Some(Arc::new(pool)),
config,
})
}
pub fn is_active(&self) -> bool {
self.pool.is_some()
}
pub fn pool(&self) -> Option<&Arc<PhpPool>> {
self.pool.as_ref()
}
pub fn config(&self) -> &PhpConfig {
&self.config
}
#[allow(clippy::too_many_arguments)]
pub fn execute(
&self,
request_path: &str,
method: &str,
uri: &str,
query_string: &str,
content_type: Option<&str>,
body: Vec<u8>,
cookies: Option<&str>,
headers: Vec<(String, String)>,
remote_addr: Option<&str>,
server_name: Option<&str>,
server_port: u16,
https: bool,
) -> Option<PhpResponse> {
let pool = self.pool.as_ref()?;
if body.len() > self.config.max_body_bytes {
return Some(PhpResponse::Error(format!(
"Request body too large ({} bytes, max {})",
body.len(),
self.config.max_body_bytes,
)));
}
let script_path = if self.config.is_worker_mode() {
self.config.worker_script.clone().unwrap_or_default()
} else {
self.config.resolve_script(request_path)?
};
match pool.execute(
script_path,
method.to_string(),
uri.to_string(),
query_string.to_string(),
content_type.map(|s| s.to_string()),
body,
cookies.map(|s| s.to_string()),
headers,
remote_addr.map(|s| s.to_string()),
server_name.map(|s| s.to_string()),
server_port,
https,
) {
Ok(resp) => Some(resp),
Err(e) => {
tracing::error!(path = %request_path, error = %e, "PHP execution failed");
Some(PhpResponse::Error("PHP execution failed".into()))
}
}
}
pub fn stats(&self) -> PhpPoolStats {
self.pool.as_ref().map(|p| p.stats()).unwrap_or_default()
}
pub fn status_json(&self) -> serde_json::Value {
let stats = self.stats();
serde_json::json!({
"active": self.is_active(),
"document_root": self.config.document_root,
"workers": stats.workers,
"workers_busy": stats.active,
"total_requests": stats.total_requests,
"total_errors": stats.total_errors,
"avg_exec_time_us": stats.avg_exec_time_us,
})
}
pub fn prometheus_metrics(&self) -> String {
if !self.is_active() {
return String::new();
}
let stats = self.stats();
format!(
"# HELP bext_php_workers Number of PHP worker threads\n\
# TYPE bext_php_workers gauge\n\
bext_php_workers {}\n\
# HELP bext_php_workers_active Currently busy PHP workers\n\
# TYPE bext_php_workers_active gauge\n\
bext_php_workers_active {}\n\
# HELP bext_php_requests_total Total PHP requests processed\n\
# TYPE bext_php_requests_total counter\n\
bext_php_requests_total {}\n\
# HELP bext_php_errors_total Total PHP execution errors\n\
# TYPE bext_php_errors_total counter\n\
bext_php_errors_total {}\n\
# HELP bext_php_exec_time_avg_us Average PHP execution time (microseconds)\n\
# TYPE bext_php_exec_time_avg_us gauge\n\
bext_php_exec_time_avg_us {}\n",
stats.workers,
stats.active,
stats.total_requests,
stats.total_errors,
stats.avg_exec_time_us,
)
}
}
impl Drop for PhpState {
fn drop(&mut self) {
if self.pool.is_some() {
tracing::info!("Shutting down PHP runtime");
if let Some(pool) = self.pool.take() {
if let Ok(pool) = Arc::try_unwrap(pool) {
pool.shutdown();
}
}
unsafe {
ffi::bext_php_module_shutdown();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_config_creates_inactive_state() {
let config = PhpConfig {
enabled: false,
..PhpConfig::default()
};
let state = PhpState::init(config).unwrap();
assert!(!state.is_active());
assert!(state.pool().is_none());
}
#[test]
fn stats_default_is_zero() {
let config = PhpConfig::default();
let state = PhpState::init(config).unwrap();
let stats = state.stats();
assert_eq!(stats.workers, 0);
assert_eq!(stats.active, 0);
assert_eq!(stats.total_requests, 0);
assert_eq!(stats.total_errors, 0);
assert_eq!(stats.avg_exec_time_us, 0);
}
#[test]
fn status_json_disabled() {
let config = PhpConfig::default();
let state = PhpState::init(config).unwrap();
let json = state.status_json();
assert_eq!(json["active"], false);
assert_eq!(json["workers"], 0);
assert_eq!(json["total_requests"], 0);
}
#[test]
fn prometheus_empty_when_disabled() {
let config = PhpConfig::default();
let state = PhpState::init(config).unwrap();
assert!(state.prometheus_metrics().is_empty());
}
#[test]
fn execute_returns_none_when_disabled() {
let config = PhpConfig::default();
let state = PhpState::init(config).unwrap();
let result = state.execute(
"/index.php",
"GET",
"/index.php",
"",
None,
Vec::new(),
None,
Vec::new(),
None,
None,
80,
false,
);
assert!(result.is_none());
}
#[test]
fn config_accessor() {
let config = PhpConfig {
document_root: "/custom/root".into(),
..PhpConfig::default()
};
let state = PhpState::init(config).unwrap();
assert_eq!(state.config().document_root, "/custom/root");
assert!(!state.config().enabled);
}
#[test]
fn php_response_ok_clone() {
let resp = PhpResponse::Ok {
status: 200,
body: b"<h1>Hello</h1>".to_vec(),
headers: vec![("Content-Type".into(), "text/html".into())],
exec_time_us: 1500,
};
let cloned = resp.clone();
match cloned {
PhpResponse::Ok {
status,
body,
headers,
exec_time_us,
} => {
assert_eq!(status, 200);
assert_eq!(body, b"<h1>Hello</h1>");
assert_eq!(headers.len(), 1);
assert_eq!(exec_time_us, 1500);
}
PhpResponse::Error(_) => panic!("Expected Ok"),
}
}
#[test]
fn php_response_error_clone() {
let resp = PhpResponse::Error("timeout".into());
let cloned = resp.clone();
match cloned {
PhpResponse::Error(msg) => assert_eq!(msg, "timeout"),
PhpResponse::Ok { .. } => panic!("Expected Error"),
}
}
#[test]
fn php_response_debug() {
let resp = PhpResponse::Ok {
status: 404,
body: Vec::new(),
headers: Vec::new(),
exec_time_us: 0,
};
let debug = format!("{:?}", resp);
assert!(debug.contains("404"));
}
#[test]
fn pool_stats_default() {
let stats = pool::PhpPoolStats::default();
assert_eq!(stats.workers, 0);
assert_eq!(stats.active, 0);
assert_eq!(stats.total_requests, 0);
assert_eq!(stats.total_errors, 0);
assert_eq!(stats.avg_exec_time_us, 0);
}
#[test]
fn pool_stats_debug() {
let stats = pool::PhpPoolStats {
workers: 4,
active: 2,
total_requests: 1000,
total_errors: 5,
avg_exec_time_us: 250,
};
let debug = format!("{:?}", stats);
assert!(debug.contains("workers: 4"));
assert!(debug.contains("total_requests: 1000"));
}
#[test]
fn execute_rejects_oversized_body() {
let config = PhpConfig {
enabled: false,
max_body_bytes: 100,
..PhpConfig::default()
};
let state = PhpState::init(config).unwrap();
assert_eq!(state.config().max_body_bytes, 100);
}
#[test]
fn prometheus_format_valid() {
let config = PhpConfig::default();
let state = PhpState::init(config).unwrap();
let prom = state.prometheus_metrics();
assert!(prom.is_empty());
}
#[test]
fn status_json_has_all_fields() {
let config = PhpConfig::default();
let state = PhpState::init(config).unwrap();
let json = state.status_json();
assert!(json.get("active").is_some());
assert!(json.get("document_root").is_some());
assert!(json.get("workers").is_some());
assert!(json.get("workers_busy").is_some());
assert!(json.get("total_requests").is_some());
assert!(json.get("total_errors").is_some());
assert!(json.get("avg_exec_time_us").is_some());
}
#[test]
fn worker_mode_config_stored() {
let config = PhpConfig {
worker_script: Some("./worker.php".into()),
..PhpConfig::default()
};
let state = PhpState::init(config).unwrap();
assert_eq!(
state.config().worker_script.as_deref(),
Some("./worker.php")
);
}
#[test]
fn cache_responses_config_stored() {
let config = PhpConfig {
cache_responses: false,
..PhpConfig::default()
};
let state = PhpState::init(config).unwrap();
assert!(!state.config().cache_responses);
}
#[test]
fn php_mode_classic_debug() {
let mode = pool::PhpMode::Classic;
let debug = format!("{:?}", mode);
assert_eq!(debug, "Classic");
}
#[test]
fn php_mode_worker_debug() {
let mode = pool::PhpMode::Worker {
script: "/opt/app/worker.php".into(),
};
let debug = format!("{:?}", mode);
assert!(debug.contains("Worker"));
assert!(debug.contains("worker.php"));
}
#[test]
fn php_mode_clone() {
let mode = pool::PhpMode::Worker {
script: "w.php".into(),
};
let cloned = mode.clone();
match cloned {
pool::PhpMode::Worker { script } => assert_eq!(script, "w.php"),
_ => panic!("Expected Worker"),
}
}
#[test]
fn php_response_ok_empty_body() {
let resp = PhpResponse::Ok {
status: 204,
body: Vec::new(),
headers: Vec::new(),
exec_time_us: 0,
};
match resp {
PhpResponse::Ok { status, body, .. } => {
assert_eq!(status, 204);
assert!(body.is_empty());
}
_ => panic!("Expected Ok"),
}
}
#[test]
fn php_response_ok_large_body() {
let body = vec![0x42u8; 10 * 1024 * 1024]; let resp = PhpResponse::Ok {
status: 200,
body,
headers: vec![("Content-Type".into(), "application/octet-stream".into())],
exec_time_us: 5000,
};
match resp {
PhpResponse::Ok { body, .. } => assert_eq!(body.len(), 10 * 1024 * 1024),
_ => panic!("Expected Ok"),
}
}
#[test]
fn php_response_error_empty_message() {
let resp = PhpResponse::Error(String::new());
match resp {
PhpResponse::Error(msg) => assert!(msg.is_empty()),
_ => panic!("Expected Error"),
}
}
#[test]
fn php_response_ok_many_headers() {
let headers: Vec<(String, String)> = (0..100)
.map(|i| (format!("X-Header-{}", i), format!("value-{}", i)))
.collect();
let resp = PhpResponse::Ok {
status: 200,
body: b"ok".to_vec(),
headers,
exec_time_us: 10,
};
match resp {
PhpResponse::Ok { headers, .. } => assert_eq!(headers.len(), 100),
_ => panic!("Expected Ok"),
}
}
#[test]
fn php_response_5xx_status() {
let resp = PhpResponse::Ok {
status: 503,
body: b"Service Unavailable".to_vec(),
headers: Vec::new(),
exec_time_us: 0,
};
match resp {
PhpResponse::Ok { status, .. } => assert_eq!(status, 503),
_ => panic!("Expected Ok"),
}
}
#[test]
fn execute_body_size_enforced() {
let config = PhpConfig {
max_body_bytes: 10,
..PhpConfig::default()
};
let state = PhpState::init(config).unwrap();
let result = state.execute(
"/index.php",
"POST",
"/index.php",
"",
None,
vec![0u8; 100], None,
Vec::new(),
None,
None,
80,
false,
);
assert_eq!(state.config().max_body_bytes, 10);
}
#[test]
fn error_messages_generic() {
let resp = PhpResponse::Error("PHP execution failed".into());
match resp {
PhpResponse::Error(msg) => {
assert!(!msg.contains("/home/"));
assert!(!msg.contains("stack trace"));
assert!(!msg.contains("Fatal error"));
}
_ => panic!("Expected Error"),
}
}
#[test]
fn bridge_no_jsc_returns_error() {
let result = crate::bridge::jsc_render("{}");
assert!(result.contains("not available"));
}
#[test]
fn bridge_no_php_returns_error() {
let result = crate::bridge::php_call("GET", "/api/test", None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not available"));
}
}