环境
webman-framework v2.2.3
workerman v5.2.2
PHP 8.3.33 (NTS, CLI)
Swoole5.1.0(所有 webman 进程 eventLoop => Workerman\Events\Swoole::class)
OSLinux(CachyOS,内核 7.2.0)
问题描述
Webman\Finder\Finder::scanDirectory() 使用 RecursiveDirectoryIterator 遍历控制器目录。在 Workerman 的 worker 进程(fork() 之后)中,该遍历可能失败——有时会报:
RecursiveDirectoryIterator::__construct(...): Failed to open directory: Not a directory
有时则静默失败:异常被 catch (\Throwable) { / Ignore unreadable directories / } 吞掉,返回空数组。
当 ControllerFinder::files('*') 返回空数组时,Route::loadAnnotationRoutes() 会静默返回,一条注解路由都不会注册:
protected static function loadAnnotationRoutes(): void
{
$controllerFiles = ControllerFinder::files('*');
if (!$controllerFiles) {
return; // 静默跳过——零条注解路由
}
// ...
}
结果:所有注解路由 URL(控制器上使用 #[Get] / #[RouteGroup] 定义的路由)全部落到 fallback 处理器返回 404;而通过 Route::add() 在 config/route.php 中注册的路由不受影响,正常工作。
最小复现
触发条件:在 config/route.php 中、loadAnnotationRoutes() 执行之前,存在任何非平凡的文件系统操作(递归遍历目录 + 读取/require_once 文件)。
Demo:
<?php
declare(strict_types=1);
use support\Request;
use Webman\Route;
// ── Demo:注解路由加载之前做大量文件系统操作 ──
function walk_tree(string $dir): void
{
$entries = @scandir($dir);
if (!is_array($entries)) {
return;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $dir . '/' . $entry;
if (is_dir($path)) {
walk_tree($path);
} elseif (str_ends_with($path, '.php')) {
// 模拟加载文件(如反射、require_once、file_get_contents 等)
@file_get_contents($path);
}
}
}
walk_tree(app_path()); // 遍历整个 app/ 目录树
// 这里注册的闭包路由不受影响,始终可用
Route::any('/demo', function (Request $request) {
return response('demo ok');
});
再写一个带注解路由的控制器(如 app/controller/UserController.php):
<?php
namespace app\controller;
use support\annotation\route\Get;
class UserController
{
#[Get('/user/list')]
public function list()
{
return response('user list');
}
}
复现步骤:
PHP 8.3 + Workerman 5.2 + webman-framework 2.2 + Swoole 5.1 事件循环;
把上面的 Demo 代码放进 config/route.php;
执行 php start.php start -d;
GET /user/list → 404(fallback)——注解路由缺失;
GET /demo → 200——Route::add() 注册的路由正常;
多次重启:是否触发取决于启动时序,但单次运行内状态是确定的(坏掉的那次运行会一直坏到重启)。
补充观察:
把 scanDirectory() 的 RecursiveDirectoryIterator 实现换成 scandir() 递归实现后,问题完全消失(多次重启稳定正常);
在 config/route.php 中、遍历之前加一次 file_put_contents() 调用(一次文件写入 I/O),问题也会消失——说明启动阶段的文件系统操作顺序/时序是关键;
同样的 Not a directory 错误此前在本项目 worker 进程中直接使用 RecursiveDirectoryIterator 时也遇到过,换成 scandir() 后稳定解决。
期望行为
scanDirectory() 在 worker 进程中应能稳定枚举文件;至少遍历失败时不应静默返回空数组——应记录日志或抛出异常,让注解路由注册失败可见,而不是出现「所有接口静默 404」的现象。
根因分析
很可能与 Workerman 多进程模型 fork() 之后的目录句柄/迭代器状态有关。在 Swoole 事件循环下,阻塞式文件系统函数会被 Swoole 的 runtime hook 接管,官方文档也警告过这种 hook 会改变 PHP 的某些默认行为。启动阶段(注解路由扫描之前)的大量文件系统操作(递归 scandir + 文件读取)会使迭代器失败的概率大幅上升。
修复建议
Finder::scanDirectory() 失败时记录日志,而不是静默返回 [](避免 loadAnnotationRoutes() 静默跳过);并且/或者
将基于 RecursiveDirectoryIterator 的遍历替换为基于 scandir() 的递归实现(补充说明:本项目此前遇到过同类问题,换成 scandir() 后稳定解决)。
涉及文件
vendor/workerman/webman-framework/src/Finder/Finder.php —— scanDirectory() 使用 RecursiveDirectoryIterator,吞掉 \Throwable 后返回 []
vendor/workerman/webman-framework/src/Finder/ControllerFinder.php —— findControllerFiles() 依赖 Finder::find()
vendor/workerman/webman-framework/src/Route.php —— loadAnnotationRoutes() 在 $controllerFiles 为空时静默返回
感谢反馈,已经兼容, workerman/webman-framework 2.2.4
收到