From ae1db02a296126bf44b713bfd2fa3ece474ac0c6 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Fri, 23 Oct 2026 19:47:12 +0200 Subject: [PATCH 1/2] Move exitImmediately() to ForkedChild, a shadowed class in the API Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Process/ForkedChildTerminator.php | 11 ++--- src/Turbo/ForkedChild.php | 29 ++++++++++++ src/Turbo/TurboNotActiveException.php | 20 +++++++++ turbo-ext/README.md | 15 ++++--- turbo-ext/analysis-stub.php | 4 -- turbo-ext/src/ForkedChild.cpp | 63 +++++++++++++++++++++++++++ turbo-ext/src/generated/ForkedChild.h | 40 +++++++++++++++++ turbo-ext/src/main.cpp | 35 --------------- turbo-ext/tests/exit-immediately.php | 45 ++++++++++++------- turbo-ext/tests/smoke.php | 1 + 10 files changed, 199 insertions(+), 64 deletions(-) create mode 100644 src/Turbo/ForkedChild.php create mode 100644 src/Turbo/TurboNotActiveException.php create mode 100644 turbo-ext/src/ForkedChild.cpp create mode 100644 turbo-ext/src/generated/ForkedChild.h diff --git a/src/Process/ForkedChildTerminator.php b/src/Process/ForkedChildTerminator.php index 0452f589d89..0ccfb2a97c9 100644 --- a/src/Process/ForkedChildTerminator.php +++ b/src/Process/ForkedChildTerminator.php @@ -2,8 +2,8 @@ namespace PHPStan\Process; +use PHPStan\Turbo\ForkedChild; use PHPStan\Turbo\TurboExtensionEnabler; -use PHPStanTurbo\Runtime; use function register_shutdown_function; /** @@ -25,9 +25,10 @@ * Everything the parent reads from the child, the results over the socket * and the captured output, has gone through unbuffered fds by then. * - * PHP itself has no _exit(); the turbo extension provides it, and fork mode - * from a phar requires the extension anyway (see ForkParallelChecker). A - * source checkout forking without it keeps the plain exit(). + * PHP itself has no _exit(); the turbo extension provides it + * (ForkedChild::exitImmediately()), and fork mode from a phar requires the + * extension anyway (see ForkParallelChecker). A source checkout forking + * without it keeps the plain exit(). */ final class ForkedChildTerminator { @@ -39,7 +40,7 @@ public static function install(): void } register_shutdown_function(static function (): void { - Runtime::exitImmediately(); + ForkedChild::exitImmediately(); }); } diff --git a/src/Turbo/ForkedChild.php b/src/Turbo/ForkedChild.php new file mode 100644 index 00000000000..b0da4f97d70 --- /dev/null +++ b/src/Turbo/ForkedChild.php @@ -0,0 +1,29 @@ + +#else +#include +#endif + +static zend_class_entry *pt_ce_forked_child = nullptr; + +/* {{{ registration */ + +#include "reg.h" + +PT_MINIT_REGISTRATION(pt_register_forked_child) +{ + reg::Class cls("PHPStan\\Turbo\\ForkedChild"); + ptdecl::ForkedChild::declareClass(cls); + ptdecl::ForkedChild::declareProperties(cls); + + /* exitImmediately() — _exit() for a pcntl_fork()ed worker; + * ForkedChildTerminator registers it as the child's last shutdown + * function. + * + * A forked child inherits the whole parent process, every loaded + * extension with whatever background threads it started included — but + * fork() copies only the calling thread. PHP's exit() then runs + * destructors and each extension's module shutdown, and an extension + * whose shutdown waits for its threads to check out (ext-grpc's + * grpc_shutdown() without grpc.enable_fork_support, for one) waits + * forever for threads the child never had: the worker has delivered its + * results, the parent keeps polling waitpid(), and the run hangs at 100%. + * A forked child that does not exec() must end with _exit() — no + * destructors, no module shutdown, no atexit handlers; that teardown is + * the parent's. The shutdown functions have run by then, so the crash + * report (ForkedChildCrashReporter) is written, and everything the parent + * reads — the results over the socket, the captured output — went + * through unbuffered fds. + * + * The status is the engine's: what exit() was given, 255 after a fatal + * error, 0 otherwise. */ + cls.method(sigs::exitImmediately, [](INTERNAL_FUNCTION_PARAMETERS) { + ZEND_PARSE_PARAMETERS_NONE(); + + _exit(EG(exit_status)); + }); + + cls.shadow(&pt_ce_forked_child); +} + +/* }}} */ diff --git a/turbo-ext/src/generated/ForkedChild.h b/turbo-ext/src/generated/ForkedChild.h new file mode 100644 index 00000000000..27537efa05e --- /dev/null +++ b/turbo-ext/src/generated/ForkedChild.h @@ -0,0 +1,40 @@ +/* Generated by turbo-ext/bin/generate-declarations.php from + * src/Turbo/ForkedChild.php — do not edit. */ + +#ifndef PHPSTANTURBO_GENERATED_FORKED_CHILD_H +#define PHPSTANTURBO_GENERATED_FORKED_CHILD_H + +#include "../reg.h" + +namespace ptdecl::ForkedChild { + +inline void declareClass(reg::Class &cls) +{ + cls.final(); +} + +/* the properties the class declares itself, in declaration order (a used trait's come from its registrar) */ +inline void declareProperties(reg::Class &cls) +{ + (void) cls; +} + +/* the string and parameter tables the signatures below index into (see reg::Sig) */ +namespace sigtab { +inline constexpr char strings[] = + "\0" /* 0 */ + "exitImmediately"; /* 1 */ +inline constexpr reg::PackedArg args[] = { + reg::packed(0, MAY_BE_NEVER), /* exitImmediately return */ +}; +using Sig = reg::Sig; +} // namespace sigtab + +/* the signatures of the methods the class declares itself (a used trait's are in the trait's header) */ +namespace sig { +inline constexpr sigtab::Sig exitImmediately = { { 1 /* exitImmediately */, 0, 0, 0, 0, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +} // namespace sig + +} // namespace ptdecl::ForkedChild + +#endif diff --git a/turbo-ext/src/main.cpp b/turbo-ext/src/main.cpp index bd8ac04380d..53220507058 100644 --- a/turbo-ext/src/main.cpp +++ b/turbo-ext/src/main.cpp @@ -15,13 +15,6 @@ #include "Engine.h" #include "version.h" -#ifdef PHP_WIN32 -#include -#else -#include -#endif - - /* PHPStanTurbo\Runtime::configure() — cold-path configuration entry point. * TurboExtensionEnabler passes the generated class map (derived from the * ReferencedByTurboExtension attributes) that the native code resolves @@ -107,33 +100,6 @@ static void ZEND_FASTCALL runtimeTrustTypesUnder(INTERNAL_FUNCTION_PARAMETERS) RETURN_BOOL(pt_trusted_types_set_prefix(prefix)); } -/* PHPStanTurbo\Runtime::exitImmediately() — _exit() for a pcntl_fork()ed - * worker; ForkedChildTerminator registers it as the child's last shutdown - * function. - * - * A forked child inherits the whole parent process, every loaded extension - * with whatever background threads it started included — but fork() copies - * only the calling thread. PHP's exit() then runs destructors and each - * extension's module shutdown, and an extension whose shutdown waits for - * its threads to check out (ext-grpc's grpc_shutdown() without - * grpc.enable_fork_support, for one) waits forever for threads the child - * never had: the worker has delivered its results, the parent keeps - * polling waitpid(), and the run hangs at 100%. A forked child that does - * not exec() must end with _exit() — no destructors, no module shutdown, no - * atexit handlers; that teardown is the parent's. The shutdown functions - * have run by then, so the crash report (ForkedChildCrashReporter) is - * written, and everything the parent reads — the results over the socket, - * the captured output — went through unbuffered fds. - * - * The status is the engine's: what exit() was given, 255 after a fatal - * error, 0 otherwise. */ -static void ZEND_FASTCALL runtimeExitImmediately(INTERNAL_FUNCTION_PARAMETERS) -{ - ZEND_PARSE_PARAMETERS_NONE(); - - _exit(EG(exit_status)); -} - /* Whether the shared core loaded next to this library carries the same * version: a mismatched pair (a torn update of the distributed files) must * never run together — the extension then registers nothing and reports @@ -201,7 +167,6 @@ static PHP_MINIT_FUNCTION(phpstan_turbo) runtime.method("isShadowing", reg::PublicStatic, 0, {}, runtimeIsShadowing, &returnsBool); runtime.method("enablePharForkGuard", reg::PublicStatic, 1, { reg::stringArg("pharPath") }, runtimeEnablePharForkGuard); runtime.method("trustTypesUnder", reg::PublicStatic, 1, { reg::stringArg("prefix") }, runtimeTrustTypesUnder, &returnsBool); - runtime.method("exitImmediately", reg::PublicStatic, 0, {}, runtimeExitImmediately); runtime.register_(); pt_minit_registrations_run(); diff --git a/turbo-ext/tests/exit-immediately.php b/turbo-ext/tests/exit-immediately.php index eefb96e2afd..b6f0697ca90 100644 --- a/turbo-ext/tests/exit-immediately.php +++ b/turbo-ext/tests/exit-immediately.php @@ -1,7 +1,7 @@ $exited, 'status' => $status, 'markers' => $written]; } -$control = forkAndExit(false); +$control = forkAndExit(null); printf("control (exit): exited %s, markers %s\n", $control['exited'] ? 'yes' : 'no', json_encode($control['markers'])); -$terminated = forkAndExit(true); -printf("exitImmediately(): exited %s, status %s, markers %s\n", $terminated['exited'] ? 'yes' : 'no', var_export($terminated['status'], true), json_encode($terminated['markers'])); +$terminated = forkAndExit(\PHPStanTurbo\ForkedChild::class); +printf("native: exited %s, status %s, markers %s\n", $terminated['exited'] ? 'yes' : 'no', var_export($terminated['status'], true), json_encode($terminated['markers'])); + +$twinThrew = false; +try { + \PHPStan\Turbo\ForkedChild::exitImmediately(); +} catch (\PHPStan\Turbo\TurboNotActiveException) { + $twinThrew = true; +} +printf("PHP twin: threw TurboNotActiveException %s\n", $twinThrew ? 'yes' : 'no'); if ($control['exited'] || $control['markers'] !== "shutdown\ndestructor\n") { fwrite(STDERR, "FAIL: the control child did not wedge in its destructor - the workload no longer models the hang\n"); @@ -132,5 +143,9 @@ function forkAndExit(bool $terminate): array fwrite(STDERR, "FAIL: unexpected teardown ran in the child\n"); exit(1); } +if (!$twinThrew) { + fwrite(STDERR, "FAIL: the PHP twin's exitImmediately() did not throw TurboNotActiveException\n"); + exit(1); +} echo "ALL OK\n"; diff --git a/turbo-ext/tests/smoke.php b/turbo-ext/tests/smoke.php index a26c086997e..db61783b772 100644 --- a/turbo-ext/tests/smoke.php +++ b/turbo-ext/tests/smoke.php @@ -4659,6 +4659,7 @@ public function getComments(): array \PHPStan\Cache\ArenaCache::class => 'arena-smoke.php', \PHPStan\Parser\ParserRunner::class => 'parser-corpus.php', \PHPStan\Reflection\BetterReflection\SourceLocator\PhpFileCleaner::class => 'php-file-cleaner-corpus.php', + \PHPStan\Turbo\ForkedChild::class => 'exit-immediately.php', \PHPStan\Reflection\BetterReflection\SourceLocator\SymbolFinderInFiles::class => 'symbol-finder-corpus.php', \PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper::class => 'walk-trace.php', \PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper::class => 'walk-trace.php', From 0d95d51ac57587d3702e0dc43ca8801142dd1268 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Fri, 23 Oct 2026 20:44:53 +0200 Subject: [PATCH 2/2] Declare the never return type of exitImmediately() in PHPDoc PHP before 8.1 reads a native never as a class name, and the downgrade has no rule for it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Turbo/ForkedChild.php | 3 ++- turbo-ext/src/generated/ForkedChild.h | 7 +++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Turbo/ForkedChild.php b/src/Turbo/ForkedChild.php index b0da4f97d70..d7b86e9e696 100644 --- a/src/Turbo/ForkedChild.php +++ b/src/Turbo/ForkedChild.php @@ -19,9 +19,10 @@ final class ForkedChild * inherited - one waiting for threads the child never had wedges there. * Register it as the child's last shutdown function. * + * @return never * @throws TurboNotActiveException */ - public static function exitImmediately(): never + public static function exitImmediately() { throw new TurboNotActiveException(); } diff --git a/turbo-ext/src/generated/ForkedChild.h b/turbo-ext/src/generated/ForkedChild.h index 27537efa05e..79f871a2662 100644 --- a/turbo-ext/src/generated/ForkedChild.h +++ b/turbo-ext/src/generated/ForkedChild.h @@ -22,17 +22,16 @@ inline void declareProperties(reg::Class &cls) /* the string and parameter tables the signatures below index into (see reg::Sig) */ namespace sigtab { inline constexpr char strings[] = - "\0" /* 0 */ - "exitImmediately"; /* 1 */ + "exitImmediately"; /* 0 */ inline constexpr reg::PackedArg args[] = { - reg::packed(0, MAY_BE_NEVER), /* exitImmediately return */ + reg::packed(0, 0), /* placeholder: C++ has no empty arrays */ }; using Sig = reg::Sig; } // namespace sigtab /* the signatures of the methods the class declares itself (a used trait's are in the trait's header) */ namespace sig { -inline constexpr sigtab::Sig exitImmediately = { { 1 /* exitImmediately */, 0, 0, 0, 0, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig exitImmediately = { { 0 /* exitImmediately */, 0, 0, 0, reg::NoArg, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; } // namespace sig } // namespace ptdecl::ForkedChild