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.