Luther Monson, Staff Software Engineer at SUSE

Luther Monson

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

The Bugs Were Our Own Linker Wraps: Making a PHP Timeout Actually Time Out

max_execution_time did nothing in ePHPm because we had wrapped zend_set_timeout to a no-op. The SDK already had per-thread timers. We just had to stop switching them off.

8-Minute Read

Side by side: a process-wide SIGPROF timer that can land on the wrong thread, versus a per-thread POSIX timer that delivers SIGRTMIN to the PHP thread that armed it

In the stack guard post the villain was a -Wl,--wrap linker flag that turned off PHP’s C-stack overflow guard. It wasn’t the only one. Until August, max_execution_time did nothing in ePHPm on Linux, for the same reason. We had wrapped the function that arms it to a no-op. A runaway script ran until the HTTP layer’s request timeout gave up on it and sent a 504. The PHP thread kept burning CPU after that.

This one is shorter, and it’s really about a habit. A setting you parse and don’t enforce is worse than a setting you don’t have.

Why the wrap existed in the first place

When you embed PHP, you inherit its global side effects, and signals are the worst of them. PHP’s classic timeout uses setitimer(ITIMER_PROF), which delivers SIGPROF to the process. The kernel then hands it to whatever thread it likes. In php-fpm that’s fine, since each process has one PHP thread. In ePHPm, SIGPROF could land on a tokio worker thread with no PHP state at all. PHP’s handler would dereference per-request globals that don’t exist there and segfault.

So early in the project, build.rs wrapped PHP’s signal plumbing and the timeout functions. The C wrapper replaced them with no-ops:

void __wrap_zend_set_timeout(long seconds, int reset_signals)
{
    (void)seconds;
    (void)reset_signals;
    /* no-op: ePHPm manages request timeouts at the HTTP server level */
}

void __wrap_zend_unset_timeout(void)
{
    /* no-op */
}

--wrap=zend_set_timeout makes GNU ld resolve every call to zend_set_timeout inside libphp.a to __wrap_zend_set_timeout instead. It’s a clean way to override a function in a static archive you didn’t compile. At the time it was the correct call. But it meant [php] max_execution_time was parsed, documented and ignored. A docs audit landing the same week had it marked “parsed but not enforced”.

The SDK already had the fix compiled in

PHP added --enable-zend-max-execution-timers partway through the 8.1 line (8.1.18 and 8.2.5, April 2023). It replaces the process-wide itimer with a per-thread POSIX timer. The question was whether our SDK had it. php-fpm users never think about this flag, and the docs for it are thin. So I checked the artifacts directly:

  • php_config.h in the Linux ZTS SDK has #define ZEND_MAX_EXECUTION_TIMERS 1.
  • libphp.a defines zend_max_execution_timer_init, _settime and _shutdown, and they reference timer_create and timer_settime.
  • Disassembling zend_max_execution_timer_init shows the important part: timer_create(CLOCK_BOOTTIME, SIGEV_THREAD_ID -> gettid(), SIGRTMIN). The handler is installed through a raw sigaction, not through the zend_signal_* functions we also wrap.

That last point matters. SIGEV_THREAD_ID means the kernel delivers the signal to one specific thread. That’s the PHP thread that armed it. The whole reason for the wrap was gone. The source in Zend/zend_max_execution_timer.c matches what the disassembly shows:

sev.sigev_notify = SIGEV_THREAD_ID;
sev.sigev_signo = SIGRTMIN;
...
if (timer_create(ZEND_MAX_EXECUTION_TIMERS_CLOCK, &sev, &EG(max_execution_timer_timer)) != 0) {

and zend_set_timeout() calls into zend_max_execution_timer_settime() when that’s compiled in. So the only thing standing between ePHPm and a working timeout was our own linker flag.

Probe, don’t assume

The fix in ephpm#277 doesn’t just delete the wrap. Not every SDK we build against has per-thread timers. macOS doesn’t, for one. Both build scripts read php_config.h and only drop the wrap when the define is present:

let has_exec_timers = php_config_has_max_exec_timers(&sdk_path.join("include").join("php"));
if has_exec_timers {
    println!("cargo::rustc-cfg=php_max_exec_timers");
}

let mut wrap_funcs: Vec<&str> = vec![
    "zend_signal_startup",
    "zend_signal_init",
    "zend_signal_deactivate",
    "zend_signal_activate",
    "zend_signal_handler_unblock",
    // still wrapped at the time; #360 (the stack guard post) removed it
    "zend_call_stack_init",
];
if !has_exec_timers {
    // No per-thread timers: zend_set_timeout() arms a process-wide
    // setitimer(ITIMER_PROF) + SIGPROF handler, which crashes when the
    // signal lands on a tokio worker thread.
    wrap_funcs.push("zend_set_timeout");
    wrap_funcs.push("zend_unset_timeout");
}

The C side gets the matching EPHPM_NATIVE_EXEC_TIMER define, and the no-op stubs only compile when it’s absent. On builds without the timers nothing changes. The HTTP request timeout is the only ceiling, and startup warns.

The second thing turning it off: the embed SAPI

Dropping the wrap wasn’t enough on its own. The timer got created and stayed disarmed. The embed SAPI ships its own defaults, and max_execution_time=0 is one of them:

static const char HARDCODED_INI[] =
	"html_errors=0\n"
	"implicit_flush=1\n"
	"output_buffering=0\n"
	"max_execution_time=0\n"
	"max_input_time=-1\n\0";

In our setup, by the time a script ran, both the ini entry and EG(timeout_seconds) read 0. PHP’s own ini-to-timer chain armed nothing. Instead of fighting that, ePHPm records the configured value once from Rust after php_embed_init(). Then it arms the timer explicitly per request, right before executing the script:

#ifdef EPHPM_NATIVE_EXEC_TIMER
    /* php_request_startup above already created the timer but armed it from
     * EG(timeout_seconds), which the embed SAPI has reset to 0, so we arm
     * explicitly from the configured value here. reset_signals = 1 also
     * (re)installs the SIGRTMIN disposition. */
    zend_set_timeout(g_configured_max_exec_secs, 1);
#endif

Arming from a process-wide configured value has a nice side effect. A previous request’s set_time_limit(0) changes the ini entry at runtime, but it can’t leak into the next request on the same thread. set_time_limit() during a request still re-arms the timer live on top of it, the same as in php-fpm.

The result also needed its own status code. A timeout unwinds through zend_bailout() like an out-of-memory fatal. Since ephpm#255 a bailout’s partial output gets discarded. But a timeout’s output is a legitimate php-fpm-style error page. So it’s told apart by PHP’s own message and delivered:

if (PG(last_error_message)
    && strncmp(ZSTR_VAL(PG(last_error_message)),
               "Maximum execution time of", 25) == 0) {
    result = EPHPM_EXEC_TIMEOUT;
} else {
    result = EPHPM_EXEC_BAILOUT;
}

What it does now

From the PR, on a PHP-linked build of PHP 8.5.7 with max_execution_time = 2 and a 30-second request timeout:

case result
CPU busy loop 500 at 2.004 s, buffered output flushed, shutdown function ran
sleep(5) 500 at 2.002 s (CLOCK_BOOTTIME is wall clock, so sleeping counts)
set_time_limit(10) 500 at 10.002 s
set_time_limit(0) 504 at 30.002 s, only the outer request timeout fires
blocking fread on an empty FIFO 500 at 2.004 s
normal request afterwards 200, same thread, no poisoning

The FIFO row is my favorite. The timer interrupts a read that’s blocked in the kernel. The HTTP-layer timeout could never do that. It could only stop waiting for the thread, not stop the thread. Worker mode got the same treatment. It also got disarm-on-idle, so a worker sitting in take_request() for longer than the limit doesn’t fatal itself. A cross-request test proves a set_time_limit(0) in one request doesn’t leave the next one unlimited.

So there are two layers now, and the docs say so. max_execution_time is PHP’s own wall-clock limit that answers 500. It’s catchable and set_time_limit-able. [server.timeouts] request is the outer 504 backstop. Startup warns when max_execution_time >= request, since the inner limit could then never fire first.

The wider pattern: silent settings

Two linker wraps in this project switched off a PHP feature and left the config pretending otherwise. Once I started looking, the same thing turned up without any linker involved.

An idle timeout that was parsed and thrown away. [server.timeouts] idle was read into a variable and dropped. The documented 60-second default did nothing. ephpm#106 implemented it. An I/O adapter stamps activity on every byte. A re-arming watchdog triggers hyper’s graceful shutdown after a quiet window. In the same pass it turned every other silent no-op it found into either a working knob or a loud startup warning.

Typos and knobs from newer versions. A config key the binary didn’t know was silently ignored. This bit for real. per_site = true on a binary that predated the knob parsed fine and got dropped on the floor. The node came up with every tenant sharing one database, with every health check green. ephpm#434 made every config section deny_unknown_fields. An unknown key is now a startup error that names the key. The top-level struct deliberately stays lenient. figment maps every EPHPM_* environment variable to a top-level key, and ePHPm sets one itself when it runs as a Windows service. The migration note in that PR has my favorite sentence of the year: if startup fails naming a key, that key was already doing nothing.

Estimates nobody measured. Two old perf issues estimated the cost of two things. Rebuilding $_SERVER per request was put at about 0.15 ms. Round-tripping response headers as text was put at 0.05 ms. When ephpm#457 measured them, they were about 4 µs and 1 µs. That’s off by roughly 40x. The fix still went in, and it also closed a header-injection gap on the worker path. But it’s a few microseconds per request, not the 20% the estimates implied.

The common thread is a knob that’s parsed but doesn’t do anything, a feature compiled in and wrapped out, a number nobody measured. Each one looks fine from the outside. For the linker case specifically, every --wrap in an embedder’s build is a runtime feature you’ve switched off. It should have a test that fails the day its reason stops being true. The stack-guard regression tests do that now. If the guard ever goes away again, they fail with a transport error, not a status mismatch. The timeout matrix above was still run by hand when it merged. Turning it into an automated e2e test is on the follow-up list. Writing this post is a decent reminder to go do it.

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