必须用 proc_open 配合非阻塞流读取 stdout 实现实时日志推送,禁用 shell_exec/passthru;websocket 连接与任务通过 task_id 绑定并由定时器轮询推送;需清洗 ansi 码、规范换行;进程崩溃时依赖进程组隔离和磁盘落盘保障日志不丢失。

Workerman 启动子进程执行构建命令时如何捕获 stdout 实时流
Workerman 本身不阻塞,但 exec、system 这类函数会阻塞主线程且无法分段获取输出。必须用 proc_open 配合非阻塞流读取——这是实现实时日志推送的底层前提。
关键点:不能用 shell_exec 或 passthru,它们要么全量返回、要么直接输出到响应体,无法在 WebSocket 连接中分块推送。
-
proc_open返回资源句柄,需对stdout流设为非阻塞(stream_set_blocking($pipes[1], false)) - 每次循环用
fread($pipes[1], 1024)尝试读取,空字符串表示暂无新数据,不是结束 - 子进程退出后,
feof($pipes[1])才为 true,此时才应关闭管道 - 务必用
proc_get_status轮询检查子进程是否仍在运行,避免死等
WebSocket 连接与构建任务如何安全绑定和解耦
一个 WebSocket 连接对应一个构建任务,但 Workerman 的 onMessage 回调里不能长期 hold 住连接等待子进程——必须把连接 ID($connection->id)存入全局任务表,并由独立的定时器或子进程回调触发推送。
常见错误:在 onMessage 中同步执行 proc_open + 循环读取,会导致该 Worker 进程卡死,其他连接无法响应。
- 收到构建请求后,生成唯一
$task_id,将[$task_id => ['connection_id' => $connection->id, 'pipes' => [...]]]存入$_SESSION或 Redis - 用
Timer::add(0.1, function() { ... })每 100ms 扫描一次所有活跃任务,读取并广播新日志 - 任务结束(
proc_get_status()['running'] === false)后,立刻proc_close并从任务表中移除 - 连接断开时,通过
onClose清理对应$task_id的进程和管道,防止僵尸进程
日志编码、换行与前端渲染兼容性问题
构建命令(如 npm install、docker build)输出的原始字节流可能含 ANSI 转义序列、\r、\n\r 混用,直接推送给前端会导致换行错乱或控制字符残留。
不能依赖前端 JS 做清洗——WebSocket 推送前就必须处理干净,否则日志不可读。
- 用
preg_replace('/\x1B\[([0-9]{1,2}(;[0-9]{1,2})*)?[m|K]/', '', $chunk)去掉 ANSI 颜色码 - 统一 \r\n → \n,再用
str_replace("\n", "<br>", $cleaned)适配 HTML 渲染(若前端用innerHTML) - 若前端用
textContent或纯文本展示,保留 \n 即可,但需确保 UTF-8 编码不被截断(尤其中文日志) - 每次推送前检查
mb_strlen($chunk, '8bit'),避免二进制乱码混入
Worker 进程崩溃或重启时如何保障日志不丢失
Workerman 主进程 reload 或异常退出时,子进程默认会被 kill,且内存中的任务表清空——用户看到“日志突然中断”,但实际构建还在后台跑着(或已静默失败)。
这不是前端能解决的问题,必须在进程生命周期层面兜底。
- 启动子进程时加
'bypass_shell' => true和'cwd' => '/path/to/project',并用setpgid(0, 0)创建新进程组,防止主进程退出时信号级联终止 - 把任务元信息(
$task_id、命令、开始时间、PID)写入磁盘临时文件(如/tmp/build_{$task_id}.json),而非仅存在内存 - Worker 启动时扫描
/tmp/build_*.json,对仍在运行的 PID 调用posix_kill($pid, 0)检查存活,续传日志或标记失败 - 构建脚本末尾必须写入完成标记(如
file_put_contents("/tmp/build_{$task_id}.done", "success")),供恢复逻辑判断终态
实时日志最难的从来不是“怎么推”,而是“推的过程中进程挂了怎么办”——只要没做进程组隔离和磁盘状态落盘,就随时可能丢日志。











