[Bug] Swoole Worker 进程扫描目录返回空,注解路由未注册,所有请求 404

iliillililiil

环境
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 为空时静默返回

243 1 0
1个回答

walkor 打赏

感谢反馈,已经兼容, workerman/webman-framework 2.2.4

🔝