Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Releases are listed newest first.

## [Unreleased]
- Added experimental PHP `eval()` support across macOS ARM64, Linux ARM64, and Linux x86_64. Eligible literal fragments are parsed at compile time and lowered to native EIR, including direct or scope-backed caller-local synchronization; dynamic strings and unsupported literal shapes fall back to the optional statically linked `elephc-magician` EvalIR interpreter. Within the supported eval subset, the fallback preserves caller/global scope updates, dynamic functions/classes/constants, callables, reflection, builtins, exceptions, ownership/COW behavior, and PHP-visible diagnostics without requiring PHP or the Zend Engine. Bridge linking is automatic when required and can be forced with `--with-eval`; generated builtin documentation now reports AOT and eval availability separately.
- Fixed `--web --max-requests` crash-loop accounting (issue #516): workers that exit after a planned request-quota recycle now use a dedicated status that the master excludes from startup-death streaks, so sustained traffic with a low quota keeps respawning workers instead of shutting down the server; genuine setup failures, other exit codes, and signal deaths still feed the guard.

## [0.26.1]
- Expanded flow-sensitive type narrowing for PHP's common guard patterns: `int|false` and other false-sentinel unions now preserve the literal `false` subtype and narrow to their success type after a divergent `=== false` guard, without incorrectly removing a full `bool` member; `=== null` and `is_null()` guards narrow nullable values; and stable object properties can be narrowed through `instanceof`, ternaries, and throw guards. Property facts are invalidated after writes or receiver rebindings and are not retained across property hooks or `__get`, whose repeated reads may differ.
Expand Down
89 changes: 74 additions & 15 deletions crates/elephc-web/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,8 @@ fn default_workers() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
}

/// Forks one worker child that serves forever, returning the child pid in the
/// Forks one worker child that serves until a planned `--max-requests` recycle
/// (then exits with `worker::RECYCLE_EXIT_CODE`), returning the child pid in the
/// master. The child restores default signal disposition and never returns. A
/// fork failure aborts the whole process. Used for both initial spawn and respawn.
fn spawn_worker(listen: &str, handler: extern "C" fn(), cfg: WorkerConfig) -> libc::pid_t {
Expand All @@ -197,12 +198,25 @@ fn spawn_worker(listen: &str, handler: extern "C" fn(), cfg: WorkerConfig) -> li
0 => {
reset_signal_handlers_to_default();
worker::serve(listen, handler, cfg);
std::process::exit(0);
// serve() only returns when the worker stopped accepting on purpose
// after serving its --max-requests quota; exit with the recycle code
// so the reaper skips the crash-loop accounting for this death.
std::process::exit(worker::RECYCLE_EXIT_CODE);
}
pid => pid,
}
}

/// Returns true when a reaped worker's `waitpid` status is a planned
/// `--max-requests` recycle: a normal exit with `worker::RECYCLE_EXIT_CODE`.
/// Signal deaths and every other exit code are real crashes and keep feeding
/// the fast-death accounting. Without this distinction, sustained traffic with
/// a small `--max-requests` recycles workers faster than `FAST_DEATH` and the
/// master mistakes the healthy recycle churn for a startup crash loop.
fn is_planned_recycle(status: libc::c_int) -> bool {
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == worker::RECYCLE_EXIT_CODE
}

/// Server entry: parse args, prefork workers, supervise. Returns an exit code.
///
/// # Safety
Expand Down Expand Up @@ -256,21 +270,26 @@ pub extern "C" fn elephc_web_run(
continue;
}
// Crash-loop guard: if workers keep dying immediately after spawn,
// stop respawning (otherwise a failed bind fork-loops forever).
if spawned_at.map(|t| t.elapsed() < FAST_DEATH).unwrap_or(false) {
fast_deaths += 1;
if fast_deaths >= MAX_FAST_DEATHS {
eprintln!(
"elephc-web: {} workers died on startup (likely a bad --listen or a \
handler crashing every request); giving up",
fast_deaths
);
break;
// stop respawning (otherwise a failed bind fork-loops forever). A
// planned --max-requests recycle is exempt: it neither increments
// nor resets the streak, so healthy recycle churn cannot trip the
// guard yet also cannot mask a real crash loop interleaved with it.
if !is_planned_recycle(status) {
if spawned_at.map(|t| t.elapsed() < FAST_DEATH).unwrap_or(false) {
fast_deaths += 1;
if fast_deaths >= MAX_FAST_DEATHS {
eprintln!(
"elephc-web: {} workers died on startup (likely a bad --listen or a \
handler crashing every request); giving up",
fast_deaths
);
break;
}
} else {
fast_deaths = 0;
}
} else {
fast_deaths = 0;
}
// A worker died unexpectedly: replace it to keep the pool at N.
// The worker is gone (crash or recycle): replace it to keep the pool at N.
let new_pid = spawn_worker(&args.listen, handler, args.worker_config());
children.push((new_pid, Instant::now()));
} else if pid == -1 {
Expand All @@ -291,3 +310,43 @@ pub extern "C" fn elephc_web_run(
}
0
}

#[cfg(test)]
mod tests {
use super::*;

/// Builds a `waitpid` status word for a normal exit with `code`. Both
/// supported unix families (Linux and macOS) encode a normal exit as
/// `code << 8` with zeroed low bits, which is what `WIFEXITED`/`WEXITSTATUS`
/// decode.
fn exited_status(code: i32) -> libc::c_int {
(code & 0xff) << 8
}

/// A normal exit with the recycle code must be classified as a planned
/// recycle so the crash-loop guard skips it (regression for issue #516:
/// sustained traffic with a small --max-requests shut the server down).
#[test]
fn recycle_exit_is_planned() {
assert!(is_planned_recycle(exited_status(worker::RECYCLE_EXIT_CODE)));
}

/// Clean exits and error exits are NOT planned recycles: they must keep
/// feeding the fast-death accounting so real crash loops still trip the guard.
#[test]
fn other_exit_codes_are_not_planned() {
assert!(!is_planned_recycle(exited_status(0)));
assert!(!is_planned_recycle(exited_status(1)));
assert!(!is_planned_recycle(exited_status(2)));
}

/// A signal death is never a planned recycle, even when the raw status bits
/// could coincide numerically: `WIFEXITED` must gate the exit-code check.
/// `waitpid` encodes "killed by signal N" as N in the low 7 bits.
#[test]
fn signal_death_is_not_planned() {
assert!(!is_planned_recycle(libc::SIGSEGV));
assert!(!is_planned_recycle(libc::SIGKILL));
assert!(!is_planned_recycle(libc::SIGTERM));
}
}
8 changes: 8 additions & 0 deletions crates/elephc-web/src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ fn reuseport_listener(addr: SocketAddr) -> std::io::Result<std::net::TcpListener
/// Process-local (each forked worker has its own copy starting at 0).
static SERVED: AtomicUsize = AtomicUsize::new(0);

/// Exit code a worker child uses for a planned `--max-requests` recycle.
/// Distinct from 0 (clean exit), 1 (worker setup/handler errors), and 2 (usage
/// errors) so the master can tell an intentional recycle from a genuine crash:
/// under sustained traffic a worker can serve its whole quota in well under the
/// master's fast-death window, and counting that as a crash-on-startup would
/// shut the server down. Checked by `server::is_planned_recycle`.
pub(crate) const RECYCLE_EXIT_CODE: i32 = 86;

/// Per-request handler time limit in seconds (`0` = none), read by `run_handler`
/// to arm a `SIGALRM` watchdog around the blocking `handler()` call.
static MAX_EXEC_SECS: AtomicU32 = AtomicU32::new(0);
Expand Down
41 changes: 41 additions & 0 deletions tests/web_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,47 @@ fn web_max_requests_recycles_and_keeps_serving() {
let _ = child.wait();
}

/// Regression for issue #516 (caveat 2): planned --max-requests recycles used to
/// count toward the master's fast-death crash-loop guard, so sustained traffic
/// recycled a worker >10 times in under a second each and the master printed
/// "giving up" and shut the whole server down. Drive enough requests through a
/// tiny quota to force well past MAX_FAST_DEATHS (10) recycles and assert the
/// master is still alive and serving at the end.
#[test]
fn web_max_requests_survives_sustained_recycle_churn() {
let dir = make_test_dir("web_maxreq_churn");
let bin = compile_web(&dir, "<?php echo 'ok';", "app");
let port = free_port();
let addr = format!("127.0.0.1:{}", port);
let mut child = Command::new(&bin)
.args(["--listen", &addr, "--workers", "1", "--max-requests", "2"])
.spawn()
.expect("spawn");
wait_until_ready(&addr);
// 30 requests with a quota of 2 forces ~15 recycles (> MAX_FAST_DEATHS).
// Recycling is not graceful, so tolerate transient failures at recycle
// boundaries — but every logical request must eventually succeed.
for i in 0..30 {
let mut ok = false;
for _ in 0..40 {
if try_http_get(&addr, "/").ends_with("ok") {
ok = true;
break;
}
std::thread::sleep(Duration::from_millis(50));
}
assert!(ok, "server gave up during recycle churn at request {}", i + 1);
}
// The master must still be running (it must NOT have printed "giving up"
// and exited after 10 fast recycles).
assert!(
child.try_wait().expect("try_wait").is_none(),
"master exited during sustained --max-requests recycle churn"
);
let _ = child.kill();
let _ = child.wait();
}

/// Verifies an uncaught exception in the handler returns HTTP 500 instead of
/// crashing the worker / dropping the connection (B1), and the server keeps
/// serving other requests afterward.
Expand Down
Loading