You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat: cross-platform force-kill primitive for stuck PHP threads (#2365)
First step of the split suggested in #2287: land the force-kill
infrastructure as a standalone, reviewable primitive independent of
background workers.
## Design
Each PHP thread, at boot from its own TSRM context, hands a
`force_kill_slot` (pointers to its `EG(vm_interrupt)` and
`EG(timed_out)`
atomic bools, plus `pthread_t` / Windows `HANDLE`) back to Go via
`go_frankenphp_store_force_kill_slot`. The slot lives on `phpThread`
and is protected by a per-thread `RWMutex` so the zero-and-release path
at thread exit cannot race an in-flight kill. From any goroutine, Go
passes the slot back to `frankenphp_force_kill_thread`, which stores
`true` into both atomic bools (waking the VM at the next opcode
boundary, routing through `zend_timeout` -> "Maximum execution time
exceeded") and delivers a platform-specific wake-up:
- **Linux/FreeBSD**: `pthread_kill(SIGRTMIN+3)` with a no-op handler
installed once via `pthread_once`, `SA_ONSTACK`, no `SA_RESTART`.
Signal delivery returns any in-flight blocking syscall with `EINTR`.
- **Windows**: `CancelSynchronousIo` + `QueueUserAPC` covers alertable
I/O and `SleepEx`. Non-alertable `Sleep` (including PHP's `usleep`)
stays uninterruptible.
- **macOS**: atomic-bool path only; threads stuck in blocking syscalls
wait for the syscall to complete naturally.
**Reserved signal**: `SIGRTMIN+3`. A PHP script that calls
`pcntl_signal(SIGRTMIN+3, ...)` clobbers this. Embedders whose own Go
code uses `SIGRTMIN+3` must patch it here. glibc NPTL reserves
`SIGRTMIN..SIGRTMIN+2`, so the offset cannot go lower.
## Drain integration
`drainWorkerThreads` waits `drainGracePeriod` (30s) for each thread to
reach `Yielding`, then arms force-kill on stragglers and **keeps
waiting** until they yield. `phpThread.shutdown` does the same. There
is no abandon path: if a thread is stuck in a syscall force-kill cannot
interrupt (macOS, Windows non-alertable Sleep), the drain blocks until
the syscall returns naturally — matching pre-patch behaviour exactly,
just typically much faster because force-kill cuts a `sleep(60)` down
to milliseconds. Operators that want a harder bound rely on their
orchestrator (systemd, k8s, supervisord) to SIGKILL the process.
`go_frankenphp_on_thread_shutdown` runs on both the healthy path and
the unhealthy-during-Shutdown path so `state.Done` is set even when
force-kill bails the thread. Without it, `phpThread.shutdown`'s
`WaitFor(state.Done)` would never unblock.
## Testing
`TestRestartWorkersForceKillsStuckThread` drives the full path via a
marker file so `RestartWorkers` only arms once the worker is proven
parked in `sleep()`, then asserts bounded elapsed time and that the
post-sleep echo never runs.
0 commit comments