Quieting PHP 8.2+ deprecated noise from older WP-CLI — three layers to keep JSON parse clean

Quieting PHP 8.2+ deprecated noise from older WP-CLI — three layers to keep JSON parse clean

消除旧版 WP-CLI 在 PHP 8.2+ 环境下的弃用警告噪音——通过三层防御确保 JSON 解析正常

Our multi-site maintenance tool fires wp plugin list --format=json against the sites it manages. One day, against a specific shared host (Xserver in Japan), this call started failing — and the failure mode was unusually subtle. Both the SSH connection test and the WP-CLI path test (wp --version) came back green. Users saw “all diagnostics pass, but the actual operation fails,” a frustrating asymmetry. Tracing it back, the root cause was PHP Deprecated warnings emitted by older WP-CLI (2.x) under PHP 8.2+ leaking into the JSON output. This post walks through the three-layer defense we used to structurally absorb the noise without losing real failures.

我们的多站点维护工具会针对所管理的站点执行 wp plugin list --format=json 命令。有一天,在某个特定的共享主机(日本的 Xserver)上,该调用开始失败,且故障表现异常隐蔽。SSH 连接测试和 WP-CLI 路径测试(wp --version)均显示正常。用户看到的现象是“所有诊断均通过,但实际操作却失败了”,这种不对称性令人沮丧。追根溯源,根本原因是旧版 WP-CLI (2.x) 在 PHP 8.2+ 环境下产生的 PHP 弃用(Deprecated)警告混入了 JSON 输出中。本文将介绍我们采用的三层防御机制,在不丢失真实错误信息的前提下,从结构上吸收这些噪音。

What was happening — Deprecated warnings on stdout

发生了什么——标准输出中的弃用警告

The raw output on a problem host looked like this: 在问题主机上的原始输出如下所示:

PHP Deprecated: Creation of dynamic property WP_CLI\Dispatcher\CompositeCommand::$longdesc is deprecated in phar:///usr/bin/wp/vendor/wp-cli/wp-cli/php/...
[ {"name":"akismet","status":"active","update":"none", ...}, ... ]

Since PHP 8.2, assigning to a dynamic property on a class without #[\AllowDynamicProperties] emits a Deprecated warning. Xserver’s /usr/bin/wp (an older WP-CLI 2.x) leans on dynamic properties internally, so running it on PHP 8.2+ produces a steady stream of those warnings. Note: PHP 8.2’s dynamic-property deprecation is a healthy direction for the language. But during the transition, you get many libraries that “warn but still work” — WP-CLI was one of them. The actual problem is the host’s php.ini: depending on display_errors, those warnings end up on stdout instead of stderr. Calling wp plugin list --format=json returns stdout containing both the warnings and the JSON, and json_decode() fails on the mixed input.

自 PHP 8.2 起,在没有使用 #[\AllowDynamicProperties] 的类上分配动态属性会触发弃用警告。Xserver 的 /usr/bin/wp(旧版 WP-CLI 2.x)在内部依赖动态属性,因此在 PHP 8.2+ 上运行时会产生源源不断的警告。注意:PHP 8.2 对动态属性的弃用是该语言发展的健康方向。但在过渡期间,许多库会出现“发出警告但仍能工作”的情况,WP-CLI 就是其中之一。真正的问题在于主机的 php.ini 配置:根据 display_errors 的设置,这些警告可能会出现在标准输出(stdout)而非标准错误(stderr)中。调用 wp plugin list --format=json 时,返回的标准输出同时包含了警告和 JSON 数据,导致 json_decode() 因混合输入而解析失败。

Why diagnostics stayed green but operations failed

为什么诊断显示正常但操作却失败了

The frustrating asymmetry came from how each test was checking the output: 这种令人沮丧的不对称性源于各项测试检查输出的方式不同:

  • SSH connection test: runs echo ok — passes as long as ok appears somewhere in stdout, extra lines are fine.

  • WP-CLI path test: runs wp --version — passes as long as a version string is found.

  • Real operation: runs wp plugin list --format=json — the JSON parse step is the only place the noise actually matters.

  • SSH 连接测试: 运行 echo ok — 只要标准输出中出现 ok 即通过,多余的行会被忽略。

  • WP-CLI 路径测试: 运行 wp --version — 只要找到版本字符串即通过。

  • 实际操作: 运行 wp plugin list --format=json — 只有在 JSON 解析步骤中,这些噪音才会产生实质影响。

To the user it looks like “all my tests are green, but the real call fails.” If your diagnostics only check exit code and “did the expected substring appear,” anything that surfaces only at the structured-output stage slips through silently. This is the same shape as the trap we hit with SSH commands failing on csh login shells — single commands pass while structured workflows break.

对用户而言,看起来就像是“所有测试都通过了,但实际调用却失败了”。如果你的诊断程序只检查退出代码和“是否出现了预期的子字符串”,那么任何仅在结构化输出阶段才显现的问题都会悄无声息地漏掉。这与我们在 csh 登录 Shell 中遇到的 SSH 命令失败陷阱如出一辙——单条命令可以执行,但结构化的工作流却会中断。

Three layers of defense

三层防御机制

You can try to suppress the warnings entirely, but you can’t fully predict every host’s php.ini configuration — so we built multiple independent layers that each catch a different leakage path.

你可以尝试完全抑制这些警告,但你无法完全预知每台主机的 php.ini 配置,因此我们构建了多个独立的层级,每一层都能捕获不同路径下的泄露。

Layer 1 — WP_CLI_PHP_ARGS to silence warnings at the source

第一层——使用 WP_CLI_PHP_ARGS 从源头屏蔽警告

WP-CLI exposes an environment variable WP_CLI_PHP_ARGS that gets forwarded to the underlying PHP invocation. We set it to mask Deprecated entries via error_reporting:

WP-CLI 提供了一个环境变量 WP_CLI_PHP_ARGS,它会被转发到底层的 PHP 调用中。我们通过 error_reporting 设置它来屏蔽弃用条目:

_WP_CLI_PHP_QUIET_ARGS = ( "-d error_reporting='E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED'" )

def _wp_with_quiet_php(wp_cli_path: str) -> str:
    """Wrap a WP-CLI call with WP_CLI_PHP_ARGS to suppress Deprecated warnings."""
    return (
        f"WP_CLI_PHP_ARGS={shlex.quote(_WP_CLI_PHP_QUIET_ARGS)} "
        f"{wp_cli_path}"
    )

shlex.quote keeps the value safely escaped for the shell, and the function plays nicely with our existing per-site WP-CLI path override feature. Parse and Fatal errors still surface — only Deprecated/Notice-level noise gets quieted.

shlex.quote 确保了该值在 Shell 中被安全转义,并且该函数与我们现有的“按站点覆盖 WP-CLI 路径”功能兼容良好。解析错误(Parse error)和致命错误(Fatal error)仍会显示,只有弃用/通知级别的噪音会被屏蔽。

Layer 2 — strip noise lines before JSON parse

第二层——在 JSON 解析前剔除噪音行

Layer 1 cleans up most environments, but if the host overrides error_reporting again at runtime (php.ini -> ini_set() chain), warnings can still slip through. As defense in depth, we strip recognized noise lines from stdout before parsing.

第一层可以清理大多数环境,但如果主机在运行时再次覆盖了 error_reporting(通过 php.ini -> ini_set() 链),警告仍可能漏出。作为纵深防御,我们在解析前从标准输出中剔除已识别的噪音行。

_PHP_NOISE_LINE_RE = re.compile(
    r'^\s*PHP\s+(Deprecated|Warning|Notice|Strict Standards):.*$',
    re.MULTILINE | re.IGNORECASE
)

def _strip_php_noise(text: str) -> str:
    """Remove PHP Deprecated/Warning/Notice/Strict Standards lines from stdout.
    Parse error / Fatal error are NOT stripped — those are real failures the user should see."""
    return _PHP_NOISE_LINE_RE.sub('', text)

The deliberate omission of Parse error and Fatal error matters. Those mean the operation actually broke, and the user needs to see them. The regex enumeration of four noise categories draws the line cleanly between “annoyance” and “actual failure.”

刻意忽略 Parse errorFatal error 是至关重要的。这些错误意味着操作确实中断了,用户必须看到它们。通过正则表达式枚举四类噪音,清晰地划定了“干扰”与“实际故障”之间的界限。

Layer 3 — try JSON even when exit code is nonzero

第三层——即使退出代码非零也尝试解析 JSON

Layers 1 and 2 catch most cases, but a small number of hosts return exit code 1 just because of warnings while leaving valid JSON in stdout. Fabric (paramiko) reports res.ok = False, but the parseable data is right there.

前两层捕获了大多数情况,但仍有少数主机仅因警告就返回退出代码 1,同时在标准输出中留下了有效的 JSON。Fabric (paramiko) 会报告 res.ok = False,但可解析的数据其实就在那里。

stdout_clean = _strip_php_noise(res.stdout or '').strip()
plugins = None
if stdout_clean:
    try:
        plugins = json.loads(stdout_clean)
    except json.JSONDecodeError:
        plugins = None

if plugins is None:
    # Only here do we conclude "no JSON" — fall to error if not res.ok:
    return error_response(res.stderr or res.stdout)

The trick is try JSON before trusting the exit code. If stdout contains a valid structure, treat the call as successful even with a nonzero exit.

诀窍是在信任退出代码之前先尝试解析 JSON。如果标准输出包含有效的结构,即使退出代码非零,也应将该调用视为成功。

Spread the fix across all three APIs at once

将修复方案同时应用到所有三个 API

Same principle as V12 (the csh portability bug): when you find this kind of issue, grep for the same pattern across the codebase and fix everywhere at once. The plugin-list fetch lived in three call sites:

遵循与 V12(csh 可移植性 Bug)相同的原则:当你发现此类问题时,应在整个代码库中搜索相同模式,并一次性修复所有位置。插件列表获取功能存在于三个调用点:

  1. /api/fetch_plugins — the cross-site plugin dashboard

  2. /api/site_plugins — the per-site plugin list modal

  3. _do_fetch_pending_plugins_for_site — the maintenance-time pending-update scan

  4. /api/fetch_plugins — 跨站点插件仪表板

  5. /api/site_plugins — 单站点插件列表模态框

  6. _do_fetch_pending_plugins_for_site — 维护期间的待更新扫描

All three were rewritten to use _wp_with_quiet_php + _strip_php_noise + JSON-first parsing. Fixing only one would have left the same regression alive on a different path. For regression defense, tests/test_wp_cli_php_noise.py ships with 18 cases (noise-line removal, Parse error preservation, env-var formatting, shlex quoting, compatibility with per-site WP-CLI path override, and presence checks for all three APIs).

这三处全部重写为使用 _wp_with_quiet_php + _strip_php_noise + 优先 JSON 解析的模式。只修复其中一处会留下同样的回归隐患。为了防止回归,我们编写了 tests/test_wp_cli_php_noise.py,其中包含了 18 个测试用例(涵盖噪音行剔除、解析错误保留、环境变量格式化、shlex 转义、与单站点 WP-CLI 路径覆盖的兼容性,以及对所有三个 API 的存在性检查)。