folk-runtime-embed 0.1.4

Embedded PHP runtime for Folk — PHP interpreter runs in-process via FFI
Documentation
//! `Runtime` implementation for embedded PHP.
//!
//! Spawns dedicated OS threads, each with its own PHP interpreter.
//! Optionally warms `OPcache` on a master thread before spawning workers.

use anyhow::Result;
use async_trait::async_trait;
use folk_core::runtime::{Runtime, WorkerHandle};
use tracing::{debug, info};

use crate::handle::EmbedWorkerHandle;
use crate::php::PhpInstance;
use crate::worker::spawn_worker_thread;

/// Configuration for the embed runtime.
#[derive(Debug, Clone)]
pub struct EmbedConfig {
    /// Optional PHP bootstrap script to load on each worker.
    /// For Laravel: `vendor/bin/folk-worker` (same as pipe/fork).
    pub script: Option<String>,

    /// Files to preload into `OPcache` during warmup.
    /// Typically `["vendor/autoload.php"]` for Composer projects.
    /// If empty, no warmup phase runs.
    pub warmup_files: Vec<String>,
}

/// Embedded PHP runtime — spawns worker threads instead of processes.
pub struct EmbedRuntime {
    config: EmbedConfig,
    warmup_done: bool,
}

impl EmbedRuntime {
    pub fn new(config: EmbedConfig) -> Self {
        Self {
            config,
            warmup_done: false,
        }
    }

    /// Run the `OPcache` warmup phase on a dedicated thread.
    ///
    /// Boots a temporary PHP instance, loads the specified files
    /// (triggering `OPcache` compilation into SHM), then shuts down.
    /// Worker threads spawned after this inherit warm bytecode.
    pub async fn warmup(&mut self) -> Result<()> {
        if self.config.warmup_files.is_empty() {
            debug!("no warmup files configured, skipping `OPcache` warmup");
            self.warmup_done = true;
            return Ok(());
        }

        let files = self.config.warmup_files.clone();

        let result = tokio::task::spawn_blocking(move || warmup_opcache(&files)).await?;

        self.warmup_done = true;
        result
    }
}

#[async_trait]
impl Runtime for EmbedRuntime {
    async fn spawn(&self) -> Result<Box<dyn WorkerHandle>> {
        if !self.warmup_done && !self.config.warmup_files.is_empty() {
            debug!("`OPcache` warmup not run yet — workers will compile on first request");
        }

        let (thread, cmd_tx, task_resp_rx, control_rx, worker_id) =
            spawn_worker_thread(self.config.script.clone());

        Ok(Box::new(EmbedWorkerHandle::new(
            worker_id,
            cmd_tx,
            task_resp_rx,
            control_rx,
            thread,
        )))
    }
}

/// Runs on a blocking thread: boots PHP, loads files to warm `OPcache`.
fn warmup_opcache(files: &[String]) -> Result<()> {
    info!(files = ?files, "starting `OPcache` warmup");

    let mut php = PhpInstance::boot_custom_sapi()?;

    php.request_startup()?;

    for file in files {
        debug!(file, "warming `OPcache`");
        // require_once loads and compiles the file → `OPcache` stores bytecode in SHM
        let code = format!("require_once '{}';", file.replace('\'', "\\'"));
        if let Err(e) = php.eval(&code) {
            tracing::warn!(file, error = ?e, "warmup file failed (non-fatal)");
        }
    }

    // Check `OPcache` status
    let status = php.eval(
        "if (function_exists('opcache_get_status')) { \
            $s = opcache_get_status(false); \
            echo json_encode(['cached_scripts' => $s['opcache_statistics']['num_cached_scripts'] ?? 0, \
                'hits' => $s['opcache_statistics']['hits'] ?? 0, \
                'memory_used' => $s['memory_usage']['used_memory'] ?? 0]); \
        } else { echo '{}'; }",
    );

    if let Ok(result) = status {
        info!(opcache_status = %result.output, "`OPcache` warmup complete");
    }

    php.request_shutdown();
    // PhpInstance::drop() shuts down PHP but `OPcache` SHM persists in-process

    info!("`OPcache` warmup finished — SHM bytecode available for workers");
    Ok(())
}