fix: join a thread whose shutdown was only requested (#2662)
`state.ShuttingDown` means two things today and the code treats them as
one. Normally `phpThread.shutdown()` is what publishes it, and it owns
the rest of the sequence: close `drainChan`, wait for `Done`, force-kill
past the grace period. But a worker that fails to boot publishes it from
inside the PHP thread, at a point where that thread still has its whole
C exit path ahead of it, down to `go_frankenphp_on_thread_shutdown()`
which is what sets `Done`.
That publication also unblocks `initWorkers()`, so `Init()` fails and
calls `shutdown()`, where `RequestSafeStateChange()` refuses the thread
that is already shutting down and returns right away.
`drainPHPThreads()` then releases `php_main()` into `sapi_shutdown()`
and `tsrm_shutdown()` with a live PHP thread still registered in TSRM,
which is exactly what the comment above that teardown says cannot
happen, and it nils `phpThreads` while that thread still indexes it from
its callbacks.
`shutdown()` now tells a shutdown that was requested from a thread that
has exited: the grace period and force-kill wait moved into
`phpThread.waitForExit()`, the owning path waits for `Done` as before,
and the refused path waits for `Done` or `Reserved` on a single
observation, so a thread that never booted returns at once and one that
is merely shutting down is joined like any other. No path changes except
a thread genuinely mid-shutdown at drain time, which today only the
boot-failure path produces.
`TestInitJoinsAThreadStuckInStartupTeardown` holds a failing worker
inside its shutdown hook and asserts `Init()` cannot return while the
thread is held. Without the fix it reports the thread still shutting
down, and the race detector flags `drainPHPThreads()` nil'ing
`phpThreads` under the live callback.
Reported by @henderkes in #2617, where the background worker
boot-failure path inherited it:
https://github.com/php/frankenphp/pull/2617#discussion_r3987787784