PAYATHON 2026

PHP exec() 执行 mono 命令时为何延迟约一分钟?

支付老李

结论

exec() 和 system() 通常不会在执行 mono 前固定等待一分钟。同一个 PHP 脚本在命令行中运行正常,却只在浏览器访问时出现延迟,问题更可能出在 Web 请求环境中:当前 PHP 请求占用了某项资源,而 PayterPay.exe 也在等待这项资源,只能等到网络请求或锁在约 60 秒后超时,才继续执行。

常见原因有两种:

  1. $url 指向同一台 Web 服务器,但服务器没有空闲 worker 处理回调请求。
  2. 当前页面开启了 PHP session,回调地址又使用相同的 session,因而一直等待 session 文件锁。

所以,单纯替换 exec() 或 system() 解决不了问题。需要解除 Web 请求和支付程序之间的循环等待。

为什么命令行执行正常

从终端执行命令时,没有正在运行的 HTTP 请求占用 PHP session、PHP-FPM worker 或单线程开发服务器。

从浏览器执行时,流程可能是:

浏览器请求 PHP
    ↓
PHP 占用 session 或 Web worker
    ↓
PHP 使用 exec() 启动 PayterPay.exe
    ↓
PayterPay.exe 访问 -u 指定的 URL
    ↓
该 URL 等待 session 锁或可用 worker
    ↓
exec() 又在等待 PayterPay.exe 结束

这样就形成了循环等待。大约一分钟后,其中一层超时,流程才能继续。

切换到 www-data 后再从命令行测试,即使运行正常,也不能排除上述问题。用户身份相同,不代表运行环境相同。Web 请求还会受到 PHP SAPI、session 锁、Web 服务器 worker、超时配置、环境变量和输出缓冲等因素影响。

首先释放 PHP session 锁

如果页面调用过 session_start(),应在执行外部程序前保存并关闭 session:

<?php

session_start();

// 读取或修改 session 数据
$userId = $_SESSION['user_id'] ?? null;

// 立即保存数据并释放 session 文件锁
session_write_close();

$command = sprintf(
    "printf 'c' | mono %s -u %s -a %s -t %s",
    escapeshellarg('/home/pi/Desktop/PayterPay/PayterPay.exe'),
    escapeshellarg($url),
    escapeshellarg((string) $price),
    escapeshellarg((string) $timeout)
);

$output = [];
$returnCode = 0;

exec($command . ' 2>&1', $output, $returnCode);

if ($returnCode !== 0) {
    throw new RuntimeException(
        "Payment command failed with exit code {$returnCode}:\n" .
        implode("\n", $output)
    );
}

调用 session_write_close() 后,当前请求仍然可以读取已经加载到 $_SESSION 中的数据,但此后对 $_SESSION 的修改不会自动保存。如果还需要写入,可以重新调用 session_start(),但不要在支付命令运行期间再次持有 session 锁。

检查 Web 服务器是否能并发处理回调

如果 $url 指向当前应用,可以直接在服务器上测试:

curl -v --max-time 10 'https://example.com/payment/callback'

需要重点检查这些情况:

  • 是否使用了 PHP 内置开发服务器 php -S。在某些配置下,它无法处理这类嵌套请求,生产环境不应依靠它完成支付流程。
  • PHP-FPM 是否只有一个可用 worker,例如 pm.max_children = 1。
  • Apache 或其他 Web 服务器的 worker 数量是否过少。
  • 回调 URL 是否指向当前正在执行支付请求的同一路由。
  • 回调接口是否也在等待当前支付流程完成。

应让另一个 worker 能够独立处理回调接口,或者把支付任务交给后台 worker,避免单个 HTTP 请求同步占用整个流程。

不要把参数直接拼入 Shell 命令

如果原示例中的 $url、$price 和 $timeout 来自请求参数,直接拼接会带来命令注入风险。至少要使用 escapeshellarg(),同时验证数值范围:

<?php

$url = filter_input(INPUT_POST, 'url', FILTER_VALIDATE_URL);
$price = filter_input(INPUT_POST, 'price', FILTER_VALIDATE_INT);
$timeout = filter_input(INPUT_POST, 'timeout', FILTER_VALIDATE_INT);

if (
    $url === false ||
    $price === false ||
    $timeout === false ||
    $price < 1 ||
    $timeout < 1
) {
    throw new InvalidArgumentException('Invalid payment parameters');
}

session_write_close();

$executable = '/home/pi/Desktop/PayterPay/PayterPay.exe';

$command = sprintf(
    "printf 'c' | mono %s -u %s -a %d -t %d",
    escapeshellarg($executable),
    escapeshellarg($url),
    $price,
    $timeout
);

exec($command . ' 2>&1', $output, $returnCode);

即使 $url 看起来只是普通网址,也可能包含 &、;、空格或引号,不能在未转义的情况下直接插入命令。

记录标准错误,找出真正等待的位置

exec() 默认只收集标准输出,而 Mono 程序、Shell 或网络库可能把错误写入标准错误。排查时应加入:

exec($command . ' 2>&1', $output, $returnCode);

error_log('Command: ' . $command);
error_log('Exit code: ' . $returnCode);
error_log("Output:\n" . implode("\n", $output));

还可以记录执行前后的时间:

$startedAt = microtime(true);

exec($command . ' 2>&1', $output, $returnCode);

$elapsed = microtime(true) - $startedAt;
error_log(sprintf('Payment command took %.3f seconds', $elapsed));

如果 Mono 程序支持分阶段日志,可以分别记录这些时间点:

  • 进程启动;
  • 开始访问 $url;
  • URL 请求返回;
  • 向支付终端发送请求;
  • 收到支付结果;
  • 等待确认字符;
  • 程序退出。

这些日志可以帮助确认一分钟究竟耗在 URL 请求、支付终端通信,还是标准输入处理上。

exec()、system() 与终端执行的区别

exec() 和 system() 都是同步调用,PHP 会一直等待外部进程退出。

它们的主要区别在于输出处理:

  • exec() 会在命令结束后返回最后一行输出,也可以通过数组取得完整输出。
  • system() 会将输出直接发送到 PHP 输出流。
  • 两者都不会自动提供交互式终端。
  • 浏览器无法直接与子进程的标准输入交互。
  • Web 请求通常还会受到 PHP、PHP-FPM、反向代理和浏览器连接超时的限制。

下面这条命令:

printf 'c' | mono ...

会在程序启动时立即将字符 c 写入标准输入,然后关闭管道,并不会等终端显示 APPROVED 后再发送。如果程序稍后才读取标准输入,字符会留在管道缓冲区中,所以这种写法可能看起来能够正常工作。但如果协议要求收到批准后才能确认,它就无法严格保证时序。

还要确认程序读取的是单个字符还是一整行。如果它通过类似 Console.ReadLine() 的方式读取,应发送换行:

printf 'c\n' | mono ...

更稳妥的结构

支付流程通常不适合完整阻塞在一次浏览器请求中。更可靠的处理方式是:

  1. HTTP 请求创建支付任务并立即返回任务 ID。
  2. 独立后台 worker 执行 Mono 程序。
  3. 支付结果写入数据库或队列。
  4. 浏览器通过轮询、SSE 或 WebSocket 获取状态。
  5. 支付回调由独立接口处理,不依赖发起请求所持有的 session。

如果暂时仍需同步执行,至少要做到:

  • 执行命令前调用 session_write_close();
  • 保证 Web 服务器有多个可用 worker;
  • 确认 $url 在 Web 运行环境中可以快速访问;
  • 捕获 stderr 和退出码;
  • 验证所有参数并进行 Shell 转义;
  • 协调 PHP、Web 服务器和支付程序的 timeout 设置。

结合现有现象,建议先检查 session 锁和回调 URL 的并发处理能力。这两项最符合“命令行正常、浏览器延迟约一分钟、切换为 www-data 后命令行仍然正常”这一组表现。

备注:内容仅供参考。