1pub mod bridge;
43pub mod config;
44pub mod context;
45mod ffi;
46pub mod pool;
47
48use std::ffi::CString;
49use std::sync::Arc;
50
51pub use config::PhpConfig;
52pub use pool::{PhpPool, PhpPoolStats, PhpResponse};
53
54pub struct PhpState {
63 pool: Option<Arc<PhpPool>>,
64 config: PhpConfig,
65}
66
67impl PhpState {
68 pub fn init(config: PhpConfig) -> anyhow::Result<Self> {
72 if !config.enabled {
73 tracing::info!("PHP runtime disabled by configuration");
74 return Ok(Self { pool: None, config });
75 }
76
77 let doc_root = std::path::Path::new(&config.document_root);
79 if !doc_root.is_dir() {
80 tracing::warn!(
81 path = %config.document_root,
82 "PHP document_root does not exist; PHP requests will return 404"
83 );
84 }
85
86 let ini_str = config.ini_entries_string();
88 let c_ini =
89 CString::new(ini_str).map_err(|e| anyhow::anyhow!("Invalid INI string: {}", e))?;
90
91 let ret = unsafe { ffi::bext_php_module_init(c_ini.as_ptr()) };
93 if ret != 0 {
94 return Err(anyhow::anyhow!(
95 "PHP module initialization failed (bext_php_module_init returned {})",
96 ret
97 ));
98 }
99
100 let worker_count = config.effective_workers();
101 let mode_label = if config.is_worker_mode() {
102 "worker"
103 } else {
104 "classic"
105 };
106 tracing::info!(
107 workers = worker_count,
108 mode = mode_label,
109 document_root = %config.document_root,
110 "PHP runtime initialized"
111 );
112
113 let pool = if config.is_worker_mode() {
116 let script = config.worker_script.as_ref().unwrap().clone();
117 PhpPool::worker(worker_count, script, config.max_requests)
118 } else {
119 PhpPool::with_max_requests(worker_count, config.max_requests)
120 }
121 .map_err(|e| anyhow::anyhow!("Failed to create PHP pool: {}", e))?;
122
123 Ok(Self {
124 pool: Some(Arc::new(pool)),
125 config,
126 })
127 }
128
129 pub fn is_active(&self) -> bool {
131 self.pool.is_some()
132 }
133
134 pub fn pool(&self) -> Option<&Arc<PhpPool>> {
136 self.pool.as_ref()
137 }
138
139 pub fn config(&self) -> &PhpConfig {
141 &self.config
142 }
143
144 #[allow(clippy::too_many_arguments)]
150 pub fn execute(
151 &self,
152 request_path: &str,
153 method: &str,
154 uri: &str,
155 query_string: &str,
156 content_type: Option<&str>,
157 body: Vec<u8>,
158 cookies: Option<&str>,
159 headers: Vec<(String, String)>,
160 remote_addr: Option<&str>,
161 server_name: Option<&str>,
162 server_port: u16,
163 https: bool,
164 ) -> Option<PhpResponse> {
165 let pool = self.pool.as_ref()?;
166
167 if body.len() > self.config.max_body_bytes {
169 return Some(PhpResponse::Error(format!(
170 "Request body too large ({} bytes, max {})",
171 body.len(),
172 self.config.max_body_bytes,
173 )));
174 }
175
176 let script_path = if self.config.is_worker_mode() {
179 self.config.worker_script.clone().unwrap_or_default()
182 } else {
183 self.config.resolve_script(request_path)?
184 };
185
186 match pool.execute(
187 script_path,
188 method.to_string(),
189 uri.to_string(),
190 query_string.to_string(),
191 content_type.map(|s| s.to_string()),
192 body,
193 cookies.map(|s| s.to_string()),
194 headers,
195 remote_addr.map(|s| s.to_string()),
196 server_name.map(|s| s.to_string()),
197 server_port,
198 https,
199 ) {
200 Ok(resp) => Some(resp),
201 Err(e) => {
202 tracing::error!(path = %request_path, error = %e, "PHP execution failed");
203 Some(PhpResponse::Error("PHP execution failed".into()))
205 }
206 }
207 }
208
209 pub fn stats(&self) -> PhpPoolStats {
211 self.pool.as_ref().map(|p| p.stats()).unwrap_or_default()
212 }
213
214 pub fn status_json(&self) -> serde_json::Value {
216 let stats = self.stats();
217 serde_json::json!({
218 "active": self.is_active(),
219 "document_root": self.config.document_root,
220 "workers": stats.workers,
221 "workers_busy": stats.active,
222 "total_requests": stats.total_requests,
223 "total_errors": stats.total_errors,
224 "avg_exec_time_us": stats.avg_exec_time_us,
225 })
226 }
227
228 pub fn prometheus_metrics(&self) -> String {
230 if !self.is_active() {
231 return String::new();
232 }
233 let stats = self.stats();
234 format!(
235 "# HELP bext_php_workers Number of PHP worker threads\n\
236 # TYPE bext_php_workers gauge\n\
237 bext_php_workers {}\n\
238 # HELP bext_php_workers_active Currently busy PHP workers\n\
239 # TYPE bext_php_workers_active gauge\n\
240 bext_php_workers_active {}\n\
241 # HELP bext_php_requests_total Total PHP requests processed\n\
242 # TYPE bext_php_requests_total counter\n\
243 bext_php_requests_total {}\n\
244 # HELP bext_php_errors_total Total PHP execution errors\n\
245 # TYPE bext_php_errors_total counter\n\
246 bext_php_errors_total {}\n\
247 # HELP bext_php_exec_time_avg_us Average PHP execution time (microseconds)\n\
248 # TYPE bext_php_exec_time_avg_us gauge\n\
249 bext_php_exec_time_avg_us {}\n",
250 stats.workers,
251 stats.active,
252 stats.total_requests,
253 stats.total_errors,
254 stats.avg_exec_time_us,
255 )
256 }
257}
258
259impl Drop for PhpState {
260 fn drop(&mut self) {
261 if self.pool.is_some() {
262 tracing::info!("Shutting down PHP runtime");
265
266 if let Some(pool) = self.pool.take() {
268 if let Ok(pool) = Arc::try_unwrap(pool) {
270 pool.shutdown();
271 }
272 }
273
274 unsafe {
275 ffi::bext_php_module_shutdown();
276 }
277 }
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
288 fn disabled_config_creates_inactive_state() {
289 let config = PhpConfig {
290 enabled: false,
291 ..PhpConfig::default()
292 };
293 let state = PhpState::init(config).unwrap();
294 assert!(!state.is_active());
295 assert!(state.pool().is_none());
296 }
297
298 #[test]
299 fn stats_default_is_zero() {
300 let config = PhpConfig::default();
301 let state = PhpState::init(config).unwrap();
302 let stats = state.stats();
303 assert_eq!(stats.workers, 0);
304 assert_eq!(stats.active, 0);
305 assert_eq!(stats.total_requests, 0);
306 assert_eq!(stats.total_errors, 0);
307 assert_eq!(stats.avg_exec_time_us, 0);
308 }
309
310 #[test]
311 fn status_json_disabled() {
312 let config = PhpConfig::default();
313 let state = PhpState::init(config).unwrap();
314 let json = state.status_json();
315 assert_eq!(json["active"], false);
316 assert_eq!(json["workers"], 0);
317 assert_eq!(json["total_requests"], 0);
318 }
319
320 #[test]
321 fn prometheus_empty_when_disabled() {
322 let config = PhpConfig::default();
323 let state = PhpState::init(config).unwrap();
324 assert!(state.prometheus_metrics().is_empty());
325 }
326
327 #[test]
328 fn execute_returns_none_when_disabled() {
329 let config = PhpConfig::default();
330 let state = PhpState::init(config).unwrap();
331 let result = state.execute(
332 "/index.php",
333 "GET",
334 "/index.php",
335 "",
336 None,
337 Vec::new(),
338 None,
339 Vec::new(),
340 None,
341 None,
342 80,
343 false,
344 );
345 assert!(result.is_none());
346 }
347
348 #[test]
349 fn config_accessor() {
350 let config = PhpConfig {
351 document_root: "/custom/root".into(),
352 ..PhpConfig::default()
353 };
354 let state = PhpState::init(config).unwrap();
355 assert_eq!(state.config().document_root, "/custom/root");
356 assert!(!state.config().enabled);
357 }
358
359 #[test]
362 fn php_response_ok_clone() {
363 let resp = PhpResponse::Ok {
364 status: 200,
365 body: b"<h1>Hello</h1>".to_vec(),
366 headers: vec![("Content-Type".into(), "text/html".into())],
367 exec_time_us: 1500,
368 };
369 let cloned = resp.clone();
370 match cloned {
371 PhpResponse::Ok {
372 status,
373 body,
374 headers,
375 exec_time_us,
376 } => {
377 assert_eq!(status, 200);
378 assert_eq!(body, b"<h1>Hello</h1>");
379 assert_eq!(headers.len(), 1);
380 assert_eq!(exec_time_us, 1500);
381 }
382 PhpResponse::Error(_) => panic!("Expected Ok"),
383 }
384 }
385
386 #[test]
387 fn php_response_error_clone() {
388 let resp = PhpResponse::Error("timeout".into());
389 let cloned = resp.clone();
390 match cloned {
391 PhpResponse::Error(msg) => assert_eq!(msg, "timeout"),
392 PhpResponse::Ok { .. } => panic!("Expected Error"),
393 }
394 }
395
396 #[test]
397 fn php_response_debug() {
398 let resp = PhpResponse::Ok {
399 status: 404,
400 body: Vec::new(),
401 headers: Vec::new(),
402 exec_time_us: 0,
403 };
404 let debug = format!("{:?}", resp);
405 assert!(debug.contains("404"));
406 }
407
408 #[test]
411 fn pool_stats_default() {
412 let stats = pool::PhpPoolStats::default();
413 assert_eq!(stats.workers, 0);
414 assert_eq!(stats.active, 0);
415 assert_eq!(stats.total_requests, 0);
416 assert_eq!(stats.total_errors, 0);
417 assert_eq!(stats.avg_exec_time_us, 0);
418 }
419
420 #[test]
421 fn pool_stats_debug() {
422 let stats = pool::PhpPoolStats {
423 workers: 4,
424 active: 2,
425 total_requests: 1000,
426 total_errors: 5,
427 avg_exec_time_us: 250,
428 };
429 let debug = format!("{:?}", stats);
430 assert!(debug.contains("workers: 4"));
431 assert!(debug.contains("total_requests: 1000"));
432 }
433
434 #[test]
437 fn execute_rejects_oversized_body() {
438 let config = PhpConfig {
441 enabled: false,
442 max_body_bytes: 100,
443 ..PhpConfig::default()
444 };
445 let state = PhpState::init(config).unwrap();
446 assert_eq!(state.config().max_body_bytes, 100);
449 }
450
451 #[test]
454 fn prometheus_format_valid() {
455 let config = PhpConfig::default();
457 let state = PhpState::init(config).unwrap();
458 let prom = state.prometheus_metrics();
460 assert!(prom.is_empty());
461 }
462
463 #[test]
466 fn status_json_has_all_fields() {
467 let config = PhpConfig::default();
468 let state = PhpState::init(config).unwrap();
469 let json = state.status_json();
470 assert!(json.get("active").is_some());
472 assert!(json.get("document_root").is_some());
473 assert!(json.get("workers").is_some());
474 assert!(json.get("workers_busy").is_some());
475 assert!(json.get("total_requests").is_some());
476 assert!(json.get("total_errors").is_some());
477 assert!(json.get("avg_exec_time_us").is_some());
478 }
479
480 #[test]
483 fn worker_mode_config_stored() {
484 let config = PhpConfig {
485 worker_script: Some("./worker.php".into()),
486 ..PhpConfig::default()
487 };
488 let state = PhpState::init(config).unwrap();
489 assert_eq!(
490 state.config().worker_script.as_deref(),
491 Some("./worker.php")
492 );
493 }
494
495 #[test]
496 fn cache_responses_config_stored() {
497 let config = PhpConfig {
498 cache_responses: false,
499 ..PhpConfig::default()
500 };
501 let state = PhpState::init(config).unwrap();
502 assert!(!state.config().cache_responses);
503 }
504
505 #[test]
508 fn php_mode_classic_debug() {
509 let mode = pool::PhpMode::Classic;
510 let debug = format!("{:?}", mode);
511 assert_eq!(debug, "Classic");
512 }
513
514 #[test]
515 fn php_mode_worker_debug() {
516 let mode = pool::PhpMode::Worker {
517 script: "/opt/app/worker.php".into(),
518 };
519 let debug = format!("{:?}", mode);
520 assert!(debug.contains("Worker"));
521 assert!(debug.contains("worker.php"));
522 }
523
524 #[test]
525 fn php_mode_clone() {
526 let mode = pool::PhpMode::Worker {
527 script: "w.php".into(),
528 };
529 let cloned = mode.clone();
530 match cloned {
531 pool::PhpMode::Worker { script } => assert_eq!(script, "w.php"),
532 _ => panic!("Expected Worker"),
533 }
534 }
535
536 #[test]
539 fn php_response_ok_empty_body() {
540 let resp = PhpResponse::Ok {
541 status: 204,
542 body: Vec::new(),
543 headers: Vec::new(),
544 exec_time_us: 0,
545 };
546 match resp {
547 PhpResponse::Ok { status, body, .. } => {
548 assert_eq!(status, 204);
549 assert!(body.is_empty());
550 }
551 _ => panic!("Expected Ok"),
552 }
553 }
554
555 #[test]
556 fn php_response_ok_large_body() {
557 let body = vec![0x42u8; 10 * 1024 * 1024]; let resp = PhpResponse::Ok {
559 status: 200,
560 body,
561 headers: vec![("Content-Type".into(), "application/octet-stream".into())],
562 exec_time_us: 5000,
563 };
564 match resp {
565 PhpResponse::Ok { body, .. } => assert_eq!(body.len(), 10 * 1024 * 1024),
566 _ => panic!("Expected Ok"),
567 }
568 }
569
570 #[test]
571 fn php_response_error_empty_message() {
572 let resp = PhpResponse::Error(String::new());
573 match resp {
574 PhpResponse::Error(msg) => assert!(msg.is_empty()),
575 _ => panic!("Expected Error"),
576 }
577 }
578
579 #[test]
580 fn php_response_ok_many_headers() {
581 let headers: Vec<(String, String)> = (0..100)
582 .map(|i| (format!("X-Header-{}", i), format!("value-{}", i)))
583 .collect();
584 let resp = PhpResponse::Ok {
585 status: 200,
586 body: b"ok".to_vec(),
587 headers,
588 exec_time_us: 10,
589 };
590 match resp {
591 PhpResponse::Ok { headers, .. } => assert_eq!(headers.len(), 100),
592 _ => panic!("Expected Ok"),
593 }
594 }
595
596 #[test]
597 fn php_response_5xx_status() {
598 let resp = PhpResponse::Ok {
599 status: 503,
600 body: b"Service Unavailable".to_vec(),
601 headers: Vec::new(),
602 exec_time_us: 0,
603 };
604 match resp {
605 PhpResponse::Ok { status, .. } => assert_eq!(status, 503),
606 _ => panic!("Expected Ok"),
607 }
608 }
609
610 #[test]
613 fn execute_body_size_enforced() {
614 let config = PhpConfig {
615 max_body_bytes: 10,
616 ..PhpConfig::default()
617 };
618 let state = PhpState::init(config).unwrap();
619 let result = state.execute(
621 "/index.php",
622 "POST",
623 "/index.php",
624 "",
625 None,
626 vec![0u8; 100], None,
628 Vec::new(),
629 None,
630 None,
631 80,
632 false,
633 );
634 assert_eq!(state.config().max_body_bytes, 10);
636 }
637
638 #[test]
639 fn error_messages_generic() {
640 let resp = PhpResponse::Error("PHP execution failed".into());
642 match resp {
643 PhpResponse::Error(msg) => {
644 assert!(!msg.contains("/home/"));
645 assert!(!msg.contains("stack trace"));
646 assert!(!msg.contains("Fatal error"));
647 }
648 _ => panic!("Expected Error"),
649 }
650 }
651
652 #[test]
653 fn bridge_no_jsc_returns_error() {
654 let result = crate::bridge::jsc_render("{}");
656 assert!(result.contains("not available"));
657 }
658
659 #[test]
660 fn bridge_no_php_returns_error() {
661 let result = crate::bridge::php_call("GET", "/api/test", None);
662 assert!(result.is_err());
663 assert!(result.unwrap_err().contains("not available"));
664 }
665}