Luther Monson, Staff Software Engineer at SUSE

Luther Monson

Go by way of PHP · Containers · VMs · Windows · Kubernetes

Building a Custom SAPI - How PHP Actually Talks to the Outside World

php-fpm, mod_php and the PHP command line all plug into PHP the same way. Here's how ePHPm does it, and the shortcut that broke WordPress.

9-Minute Read

Table mapping PHP operations like echo, header(), $_POST, $_COOKIE and $_SERVER to SAPI callbacks and to the Rust side of ePHPm

If you’ve only ever run PHP behind nginx, you’ve probably never had to think about how echo reaches a socket. When you embed PHP like ePHPm does, it’s the first thing you have to answer. The answer is a SAPI. There is very little written about implementing one. Most of what I know came from reading sapi/cli, sapi/cgi and sapi/embed in php-src. Then I found out which parts I’d misread. This is the write-up I wanted back in March.

What a SAPI is

The Server API is PHP’s boundary with whatever is hosting it. In C it’s sapi_module_struct. That’s a name plus a few dozen function pointers that the engine calls whenever it needs something from the outside world. php-fpm is a SAPI. So are Apache’s mod_php, the CLI, the built-in dev server and the embed SAPI. They all run the same engine. What differs is what these callbacks do:

Callback PHP calls it when…
ub_write anything is output: echo, print, templates, the error page
flush output is flushed
send_headers the first output byte is about to go out and headers need to be committed
read_post it needs request body bytes ($_POST, $_FILES, php://input)
read_cookies it wants the raw Cookie header to build $_COOKIE
register_server_variables it’s building $_SERVER
log_message error_log() or the engine logs something

ePHPm starts from the embed SAPI. php_embed_init() gives you a working engine with stdout-oriented defaults. Then ePHPm overwrites the callbacks with its own:

void ephpm_install_sapi(void)
{
    sapi_module.ub_write = ephpm_sapi_ub_write;
    sapi_module.flush = ephpm_sapi_flush;
    sapi_module.send_headers = ephpm_sapi_send_headers;
    sapi_module.read_post = ephpm_sapi_read_post;
    sapi_module.read_cookies = ephpm_sapi_read_cookies;
    sapi_module.register_server_variables = ephpm_sapi_register_server_variables;
    sapi_module.log_message = ephpm_sapi_log_message;

    sapi_module.name = "ephpm";
    sapi_module.pretty_name = "ePHPm Embedded Server";
}

sapi_module.name is what php_sapi_name() returns. Frameworks that branch on cli vs everything else see ephpm and treat it as a web SAPI. All of this is C, not Rust, for the reason I covered in the Rust post. PHP can longjmp out from under any of these callbacks, so they never call into Rust while the engine is live. They read and write thread-local C buffers. Rust copies things in before the script runs and out after it finishes.

Output: ub_write

Output is the simplest one. PHP hands over a pointer and a length and expects to be told how many bytes were taken. ePHPm appends to a per-thread buffer that doubles as needed:

static size_t ephpm_sapi_ub_write(const char *str, size_t str_length)
{
    if (output_len + str_length > output_cap) {
        size_t new_cap = (output_cap == 0) ? 8192 : output_cap;
        while (new_cap < output_len + str_length)
            new_cap *= 2;
        char *new_buf = realloc(output_buf, new_cap);
        if (!new_buf) return 0;
        output_buf = new_buf;
        output_cap = new_cap;
    }
    memcpy(output_buf + output_len, str, str_length);
    output_len += str_length;
    return str_length;
}

The buffer is __thread because under ZTS several threads run PHP at once. On MSVC it’s __declspec(thread), behind a macro. It’s never freed between requests, only reset to length zero. So a thread that served one big page keeps that capacity around. That’s a deliberate trade: no allocation on the hot path in exchange for some memory per thread.

Returning 0 from ub_write tells PHP the client went away. If realloc fails, that’s the honest answer.

Headers: send_headers is almost a no-op

This one surprised me. send_headers is called once, right before the first byte of output. That’s where a SAPI like php-fpm writes the status line and headers to the FastCGI stream. ePHPm doesn’t stream PHP output in this mode. It buffers the whole response, so its send_headers just says “done”:

static int ephpm_sapi_send_headers(sapi_headers_struct *sapi_headers)
{
    (void)sapi_headers;
    return SAPI_HEADER_SENT_SUCCESSFULLY;
}

The real header capture happens after the script finishes, while the request is still alive. It walks SG(sapi_headers).headers, which is the zend_llist that header() appends to. It reads SG(sapi_headers).http_response_code for the status. One subtlety: if a script never outputs anything, the default Content-Type never makes it into that list. PHP adds it as part of sending headers. So when no Content-Type was set, the capture code synthesizes one. It uses SG(sapi_headers).mimetype, or default_mimetype + default_charset.

Input: read_post and read_cookies

read_post is a cursor over the request body Rust handed in:

static size_t ephpm_sapi_read_post(char *buffer, size_t count_bytes)
{
    if (!req_post_data || req_post_data_offset >= req_post_data_len)
        return 0;

    size_t remaining = req_post_data_len - req_post_data_offset;
    size_t to_copy = remaining < count_bytes ? remaining : count_bytes;
    memcpy(buffer, req_post_data + req_post_data_offset, to_copy);
    req_post_data_offset += to_copy;
    return to_copy;
}

and read_cookies returns the raw Cookie header and lets PHP parse it.

Here’s one of the things you only learn by reading main/SAPI.c. sapi_activate() only reads and parses the POST body into $_POST if SG(server_context) is non-NULL. That pointer is how the CGI and built-in server SAPIs say “this is a real request” as opposed to a CLI run. ePHPm’s callbacks don’t use it for anything, so it gets a stable non-NULL sentinel. Without that line $_POST was quietly empty while php://input still worked. Here’s another. PHP 8.4 exports sapi_read_post_data(), but in 8.3 the same function is static inside main/SAPI.c. There’s nothing to call. ePHPm supports 8.3, so for PHP_VERSION_ID < 80400 the wrapper carries a verbatim copy of 8.4’s function.

$_SERVER from a hyper request

register_server_variables walks a list of key/value pairs that Rust built from the hyper request. Here’s the original version of that builder. It’s since been reworked to skip the per-header allocations and map names in a single ASCII pass, but the mapping is the same:

let mut vars = vec![
    ("REQUEST_METHOD".into(), self.method.clone()),
    ("REQUEST_URI".into(), self.uri.clone()),
    ("SCRIPT_FILENAME".into(), self.script_filename.to_string_lossy().into_owned()),
    ("SCRIPT_NAME".into(), script_name.clone()),
    ("DOCUMENT_ROOT".into(), self.document_root.to_string_lossy().into_owned()),
    ("SERVER_NAME".into(), self.server_name.clone()),
    ("SERVER_PORT".into(), self.server_port.to_string()),
    ("SERVER_PROTOCOL".into(), self.protocol.clone()),
    ("GATEWAY_INTERFACE".into(), "CGI/1.1".into()),
    ("QUERY_STRING".into(), self.query_string.clone()),
    ("PHP_SELF".into(), script_name),
    ("REMOTE_ADDR".into(), self.remote_addr.ip().to_string()),
    ("REMOTE_PORT".into(), self.remote_addr.port().to_string()),
    ("REDIRECT_STATUS".into(), "200".into()),
];

// Map HTTP headers to $_SERVER variables
for (name, value) in &self.headers {
    let key = match name.to_lowercase().as_str() {
        "host" => "HTTP_HOST".to_string(),
        "cookie" => "HTTP_COOKIE".to_string(),
        "content-type" => "CONTENT_TYPE".to_string(),
        "content-length" => "CONTENT_LENGTH".to_string(),
        // "Accept-Encoding" -> "HTTP_ACCEPT_ENCODING"
        _ => format!("HTTP_{}", name.to_uppercase().replace('-', "_")),
    };
    vars.push((key, value.clone()));
}

That’s the CGI/1.1 variable set every PHP framework expects. SCRIPT_NAME is derived from the resolved script path relative to the document root, not from the URI. That way it stays right after a front-controller rewrite (/blog/hello → /index.php). HTTPS=on is added for TLS requests. REDIRECT_STATUS=200 keeps code that was written for cgi.force_redirect happy.

Two sharp edges in that header loop, both worth knowing about if you write your own:

  • httpoxy. A client-sent Proxy: header becomes HTTP_PROXY, which a lot of HTTP client libraries read as “use this proxy for outbound requests.” That’s the 2016 httpoxy bug class, and the generic HTTP_* mapping walks right into it. As of last week ePHPm strips Proxy at request ingest, along with any configured JWT claims header. That happens before middleware runs and before the request crosses into PHP (#200).
  • A fixed-size table. On the C side the server variables live in a __thread array of 128 pairs. ephpm_request_add_server_var() silently drops entries past that. Sixteen-odd CGI variables plus a request with more than about a hundred headers will lose the tail. Nobody has hit it, but “silently” is the word I don’t like there.

The shortcut that broke WordPress

The interesting part of a SAPI isn’t the callbacks. It’s the lifecycle around them. A normal SAPI does this for every request:

php_request_startup()   -> superglobals built, executor fresh
php_execute_script()
php_request_shutdown()  -> user functions, classes, constants, globals, statics destroyed

In March, php_request_startup() crashed on tokio’s spawn_blocking threads. The signal and stack-probe functions it calls were the problem, same family as the SIGPROF bug. So the first version of ePHPm took a shortcut. It started one request when the thread was registered with TSRM and reused it forever. Between HTTP requests it only reset the SAPI request info and rebuilt the superglobals by hand:

  • destroy the PG(http_globals) arrays
  • rebuild $_SERVER through the callback
  • run treat_data over the query string for $_GET
  • call sapi_handle_post() for $_POST/$_FILES
  • parse cookies manually
  • merge $_REQUEST

That passed every test I had, because every test was a script that didn’t care what the previous request left behind. WordPress cares. Bryan Spears tracked it down at the end of June (9715d61). User functions, classes, constants, function statics and the global symbol table all survived between requests on the same thread. WordPress’s wp-blog-header.php checks $wp_did_header, a global. It defines WP_USE_THEMES, a constant. So on the second request to a warmed thread it decided it had already bootstrapped and skipped everything. Request #1 on a thread was a full 68KB page. Requests #2 and later were 119-byte blank pages containing a “constant already defined” warning.

By then the functions that made php_request_startup() crash were already --wrap’d to no-ops. So the fix was to do what every other SAPI does:

int ephpm_execute_request(const char *filename)
{
    /* Tear down the previous request and start a fresh one. */
    if (request_active) {
        php_request_shutdown(NULL);
        request_active = 0;
    }

    output_len = 0;
    headers_buf_len = 0;
    req_post_data_offset = 0;

    /* Populate SG(request_info) BEFORE php_request_startup(): PHP builds the
     * superglobals during startup using our callbacks, which read these. */
    SG(request_info).request_method = (char *)req_method;
    SG(request_info).request_uri = (char *)req_uri;
    SG(request_info).query_string = (char *)req_query_string;
    SG(request_info).content_type = req_content_type;
    SG(request_info).cookie_data = (char *)req_cookie_data;
    SG(request_info).content_length = (long)req_post_data_len;
    SG(request_info).path_translated = (char *)req_path_translated;
    SG(server_context) = &ephpm_server_context_marker;

    if (php_request_startup() != SUCCESS) {
        return -1;
    }
    request_active = 1;

    /* startup doesn't reset these on the embed path, so a 201 or 500 from the
     * previous request on this thread would leak into this one */
    SG(sapi_headers).http_response_code = 200;
    SG(headers_sent) = 0;
    SG(request_info).no_headers = 0;

    /* ... replay per-request INI (open_basedir etc.), then execute under SETJMP ... */
}

The ordering in there is the lesson. SG(request_info) has to be filled in before php_request_startup(). Startup is what builds the superglobals from those fields, through the callbacks. Once startup owned that job, the old hand-rebuild became actively harmful. It destroyed the arrays startup had just created and re-ran treat_data over them. That was a use-after-free inside php_default_treat_data under load. The correct amount of superglobal code in a SAPI turned out to be none. Set the inputs, let PHP build them.

The cost was about 5% on a trivial no-op script, per the commit’s own measurement. On a real page it’s nothing noticeable. OPcache’s compiled bytecode and the JIT buffer live in shared memory and survive request shutdown. It’s the classic php-fpm + OPcache model, just with threads instead of processes. WordPress went from one good page per warmed thread to 40 out of 40 under concurrency.

The status code reset in that block is the same kind of bug in miniature. http_response_code(201) on one request came back as the status of the next request on that thread. The embed path doesn’t reset it during startup.

What I’d tell someone writing a SAPI

  • Read sapi/cgi/cgi_main.c and sapi/cli/php_cli_server.c before you read the embed SAPI. Embed is the smallest, but it’s built for “run one script and exit.” The gaps are exactly the parts a server needs.
  • Never skip php_request_shutdown(). It is the isolation boundary between requests. If something crashes during request startup, fix that instead of reusing a request.
  • Set SG(request_info) before startup and let PHP build the superglobals.
  • Treat every global in SG()/PG() as suspect between requests and check what startup does and doesn’t reset.
  • Test with WordPress. It relies on more global state than anything else you’re likely to run, and it will find your lifecycle bugs immediately.

Recent Posts

Categories

About

Luther is a 20+ year software veteran specializing in backend APIs and architecting products. Employed at SUSE as the Team Lead for the Containers on Windows Team working on Rancher + Kubernetes