diff --git a/CHANGELOG.md b/CHANGELOG.md index 70e2cafa2f..347ecf9cd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/crates/elephc-web/src/server.rs b/crates/elephc-web/src/server.rs index e65df8fbb0..410718f00d 100644 --- a/crates/elephc-web/src/server.rs +++ b/crates/elephc-web/src/server.rs @@ -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 { @@ -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 @@ -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 { @@ -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)); + } +} diff --git a/crates/elephc-web/src/worker.rs b/crates/elephc-web/src/worker.rs index 263a4802a4..6c08c92b23 100644 --- a/crates/elephc-web/src/worker.rs +++ b/crates/elephc-web/src/worker.rs @@ -45,6 +45,14 @@ fn reuseport_listener(addr: SocketAddr) -> std::io::Result10 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, " 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.