use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use bytes::Bytes;
use claviron_http::ext::ConnCtx;
use claviron_http::policy::BodyNeed;
use claviron_http::{HttpExchange, RouteBody, RouteResponse};
use claviron_php::PhpRuntime;
use ext_php_rs::embed::EmbedError;
use ext_php_rs::zend::ExecutorGlobals;
use http::request::Parts;
use http::{HeaderMap, Request, Response, StatusCode};
#[cfg(test)]
use http_body_util::BodyExt;
pub use pool::{SapiThreadPoolSnapshot, SapiThreadSlotSnapshot};
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tracing::error;
use crate::error::SapiGatewayError;
use crate::sapi::callbacks::{
ClavironSapi, php_request_logged_error, reset_php_request_error_flag,
};
use crate::sapi::{
ExecutePhpError, ZEND_RESULT_CODE_SUCCESS, run_request, run_sapi_startup,
};
pub(crate) mod application;
mod body;
pub(crate) mod context;
pub(crate) mod interrupt;
pub(crate) mod pool;
pub(crate) mod startup;
use body::request::{RequestBodySource, collect_body};
use body::response::{
BodySink, CapturedResponse, STREAM_BODY_CAPACITY, StreamingSink,
body_from_receiver,
};
pub(crate) use context::{
ClavironContext, PhpExecution, PhpProgramContext, PhpWorkUnit, ProgramModel,
};
pub(crate) mod program;
#[cfg(feature = "tasks")]
pub(crate) mod task;
pub(crate) mod thread;
#[derive(Debug, Clone, Copy)]
pub(crate) struct SapiPoolSettings {
pub(crate) max_requests: Option<u64>,
pub(crate) threads_min: u8,
pub(crate) threads_max: u8,
pub(crate) threads_max_idle: u8,
pub(crate) thread_idle_timeout: Duration,
pub(crate) thread_max_life: Option<Duration>,
}
pub(crate) static SAPI_SHUTTING_DOWN: AtomicBool = AtomicBool::new(false);
static SAPI_SHUTDOWN: std::sync::RwLock<
Option<tokio_util::sync::CancellationToken>,
> = std::sync::RwLock::new(None);
pub(crate) fn sapi_shutdown_signal() -> tokio_util::sync::CancellationToken {
SAPI_SHUTDOWN
.write()
.expect("sapi shutdown lock poisoned")
.get_or_insert_with(tokio_util::sync::CancellationToken::new)
.clone()
}
pub(crate) fn cancel_sapi_shutdown_signal() {
if let Some(token) =
SAPI_SHUTDOWN.read().expect("sapi shutdown lock poisoned").as_ref()
{
token.cancel();
}
}
pub(crate) fn renew_sapi_shutdown_signal() {
*SAPI_SHUTDOWN.write().expect("sapi shutdown lock poisoned") =
Some(tokio_util::sync::CancellationToken::new());
}
static SAPI: OnceLock<Result<Arc<ClavironSapi>, String>> = OnceLock::new();
#[derive(Clone, Debug)]
pub struct PhpSapiRequest {
pub script_name: String,
pub script_file: std::path::PathBuf,
pub document_root: std::path::PathBuf,
pub document_uri: String,
pub request_uri: String,
pub query_string: String,
pub path_info: Option<String>,
}
pub(crate) struct PhpJob {
pub(crate) runtime: Arc<PhpRuntime>,
pub(crate) sapi_req: PhpSapiRequest,
pub(crate) parts: Parts,
pub(crate) conn_ctx: Option<Arc<ConnCtx>>,
pub(crate) request_body: RequestBodySource,
pub(crate) body_sink: BodySink,
pub(crate) reply:
oneshot::Sender<Result<CapturedResponse, SapiGatewayError>>,
}
#[derive(Clone, Default)]
pub struct PhpSapiEngine {
pool: Arc<OnceLock<Arc<pool::SapiThreadPool>>>,
}
impl PhpSapiEngine {
pub fn new() -> Self {
Self::default()
}
pub(crate) fn start_pool(
&self,
settings: SapiPoolSettings,
cancellation: CancellationToken,
) -> Result<Vec<JoinHandle<()>>, String> {
let SapiPoolSettings {
max_requests,
threads_min,
threads_max,
threads_max_idle,
thread_idle_timeout,
thread_max_life,
} = settings;
let pool = if let Some(pool) = self.pool.get() {
pool.resume().map_err(|in_flight| {
format!(
"PHP SAPI pool still has {in_flight} accepted request(s) from its previous generation"
)
})?;
Arc::clone(pool)
} else {
let pool = Arc::new(pool::SapiThreadPool::spawn(
max_requests,
threads_min as usize,
threads_max as usize,
threads_max_idle as usize,
thread_idle_timeout,
thread_max_life,
));
self.pool.set(Arc::clone(&pool)).map_err(|_| {
"PHP SAPI pool was initialized concurrently".to_string()
})?;
pool
};
let mut maintenance = Vec::with_capacity(2);
if let Some(handle) =
Arc::clone(&pool).spawn_rotation_manager(cancellation.child_token())
{
maintenance.push(handle);
}
if let Some(handle) = Arc::clone(&pool).spawn_idle_culler(cancellation)
{
maintenance.push(handle);
}
Ok(maintenance)
}
pub async fn drain(
&self,
grace: Option<Duration>,
) -> bool {
if let Some(pool) = self.pool.get() {
pool.drain(grace).await
} else {
true
}
}
pub(crate) fn ensure_drained(&self) -> Result<(), String> {
if let Some(pool) = self.pool.get() {
pool.ensure_drained().map_err(|pending| {
format!("PHP SAPI pool still has {pending} pending dispatch/reservation(s) from its previous generation")
})?;
}
Ok(())
}
pub fn pool_snapshot(&self) -> Option<SapiThreadPoolSnapshot> {
self.pool.get().map(|pool| pool.snapshot())
}
pub async fn execute(
&self,
runtime: Arc<PhpRuntime>,
sapi_req: PhpSapiRequest,
req: Request<RouteBody>,
exchange_tx: Option<mpsc::Sender<HttpExchange>>,
) -> Result<RouteResponse, SapiGatewayError> {
let (parts, body) = req.into_parts();
if parts.extensions.get::<claviron_http::policy::HttpPolicy>().is_none()
{
return Err(SapiGatewayError::Internal(
"HttpPolicy is required for PHP script execution".into(),
));
}
let policy = runtime.policy(&parts);
if claviron_http::body::declared_length_within(
&parts.headers,
policy.requests.max_body_size,
)
.is_err()
{
let mut response = Response::new(claviron_http::empty_body());
*response.status_mut() = StatusCode::PAYLOAD_TOO_LARGE;
return Ok(response);
}
let conn_ctx = parts.extensions.get::<Arc<ConnCtx>>().cloned();
let pool = self.pool.get().ok_or_else(|| {
SapiGatewayError::Internal(
"php thread pool not started — PhpSapiService::run not spawned yet?".to_string()
)
})?;
let _ = exchange_tx;
let request_body = if http_body::Body::is_end_stream(&body) {
RequestBodySource::buffered(Bytes::new())
} else {
let declared = http_body::Body::size_hint(&body).exact();
let whole = policy
.requests
.delivery(BodyNeed::Stream, declared, false)
.takes_whole(declared);
if whole {
let bytes =
match collect_body(body, policy.requests.max_body_size)
.await
{
Ok(bytes) => bytes,
Err(error)
if matches!(
error
.downcast_ref::<claviron_http::body::BodyLimit>(
),
Some(
claviron_http::body::BodyLimit::TooLarge { .. }
)
) =>
{
let mut response =
Response::new(claviron_http::empty_body());
*response.status_mut() =
StatusCode::PAYLOAD_TOO_LARGE;
return Ok(response);
}
Err(error) => {
return Err(SapiGatewayError::BadRequest(format!(
"request body read error: {error}"
)));
}
};
RequestBodySource::buffered(bytes)
} else {
RequestBodySource::streaming(
body,
policy.requests.max_body_size,
)
}
};
let (headers_tx, headers_rx) =
oneshot::channel::<(StatusCode, HeaderMap)>();
let (body_tx, body_rx) = mpsc::channel::<Bytes>(STREAM_BODY_CAPACITY);
let sink = StreamingSink::new(
headers_tx,
body_tx,
policy.responses.write_timeout,
policy.responses,
);
let (reply_tx, reply_rx) = oneshot::channel();
let job = PhpJob {
runtime,
sapi_req,
parts,
conn_ctx,
request_body,
body_sink: BodySink::Streaming(sink),
reply: reply_tx,
};
pool.dispatch(job, policy.requests.queue_timeout).await?;
tokio::pin!(headers_rx);
tokio::pin!(reply_rx);
let head = tokio::select! {
biased;
head = &mut headers_rx => head.ok(),
reply = &mut reply_rx => return whole_reply(reply),
};
let Some((status, headers)) = head else {
return whole_reply(reply_rx.await);
};
let mut response = Response::new(body_from_receiver(body_rx));
*response.status_mut() = status;
let dst = response.headers_mut();
for (name, value) in headers.iter() {
dst.append(name.clone(), value.clone());
}
Ok(response)
}
}
fn whole_reply(
reply: Result<
Result<CapturedResponse, SapiGatewayError>,
oneshot::error::RecvError,
>
) -> Result<RouteResponse, SapiGatewayError> {
match reply {
Ok(Ok(captured)) => Ok(captured.into_response()),
Ok(Err(err)) => Err(err),
Err(_) => Err(SapiGatewayError::BadGateway(
"php thread reply dropped".to_string(),
)),
}
}
pub(crate) fn run_php_on_thread(
runtime: Arc<PhpRuntime>,
sapi_req: PhpSapiRequest,
parts: Parts,
conn_ctx: Option<Arc<ConnCtx>>,
request_body: RequestBodySource,
body_sink: BodySink,
) -> Result<CapturedResponse, SapiGatewayError> {
#[cfg(feature = "metrics")]
let _request_guard = crate::metrics::EmbedRequestGuard::enter();
#[cfg(feature = "metrics")]
{
if let Some(len) = request_body.buffered_len() {
crate::metrics::record_request_bytes(len as u64);
}
}
let output = Arc::new(Mutex::new(CapturedResponse::default()));
let context = ClavironContext::new(
runtime,
sapi_req.clone(),
parts,
conn_ctx,
request_body,
Arc::clone(&output),
body_sink,
);
let script = sapi_req.script_file.clone();
reset_php_request_error_flag();
let _budget = interrupt::Budget::from_php();
let clean_exit_candidate = AtomicBool::new(false);
let exec_result = run_request(context, script, |err| {
let logged_php_error = php_request_logged_error();
let has_exception = ExecutorGlobals::has_exception();
let fail_request = !matches!(err, EmbedError::ExecuteScriptError)
|| logged_php_error
|| has_exception;
if fail_request {
error!(
?err,
logged_php_error, has_exception, "php sapi run_script failed"
);
} else {
clean_exit_candidate.store(true, Ordering::Relaxed);
}
fail_request
});
if let Err(ExecutePhpError::Bailout) = &exec_result {
#[cfg(feature = "metrics")]
crate::metrics::record_bailout();
}
let logged_php_error = php_request_logged_error();
let mut captured = output
.lock()
.map_err(|_| {
SapiGatewayError::Internal(
"php sapi output lock poisoned".to_string(),
)
})?
.clone();
match exec_result {
Ok(()) if logged_php_error => {
captured.mark_script_failure();
}
Ok(())
if clean_exit_candidate.load(Ordering::Relaxed)
&& captured.contains_php_fatal_output() =>
{
error!(
"php sapi script emitted fatal output after ExecuteScriptError"
);
captured.mark_script_failure();
}
Ok(()) if clean_exit_candidate.load(Ordering::Relaxed) => {
#[cfg(debug_assertions)]
tracing::debug!("php sapi script ended via PHP exit");
}
Ok(()) => {}
Err(ExecutePhpError::Bailout) => {
captured.mark_script_failure();
}
Err(err) => return Err(err.into()),
}
#[cfg(feature = "metrics")]
crate::metrics::record_response_bytes(captured.body.len() as u64);
Ok(captured)
}
pub(crate) fn ensure_sapi() -> Result<Arc<ClavironSapi>, SapiGatewayError> {
let result = SAPI.get_or_init(|| {
startup::start()?;
unsafe {
ext_php_rs::embed::ext_php_rs_sapi_startup();
}
let mut sapi = ClavironSapi::new().map_err(|err| err.to_string())?;
if run_sapi_startup(&raw mut *sapi.0) != ZEND_RESULT_CODE_SUCCESS {
return Err("php module startup failed".to_string());
}
crate::intercept::putenv::override_putenv_handler();
crate::intercept::ensure_cache_invalidation_intercepts_for_current_thread();
Ok(Arc::new(sapi))
});
result
.as_ref()
.map(Arc::clone)
.map_err(|err| SapiGatewayError::Internal(err.clone()))
}
impl From<ExecutePhpError> for SapiGatewayError {
fn from(value: ExecutePhpError) -> Self {
match value {
ExecutePhpError::InitSapiGlobalsError(err) => {
SapiGatewayError::BadRequest(err.to_string())
}
ExecutePhpError::RequestStartupFailed => {
SapiGatewayError::Internal(
"php request startup failed".to_string(),
)
}
ExecutePhpError::Bailout => {
SapiGatewayError::Internal("php bailout".to_string())
}
}
}
}
#[cfg(test)]
#[path = "../tests/engine.rs"]
mod tests;