| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Environment-extras passthrough probes for the feedback payload's JSON column. |
| 9 |
* |
| 10 |
* Collects best-effort diagnostic probes about the server environment |
| 11 |
* (MySQL globals, disk headroom, PHP SAPI, hosting class, etc.) and |
| 12 |
* packages them into a keyed array for the `environment_extras` field |
| 13 |
* of the feedback payload. |
| 14 |
* |
| 15 |
* Each probe is wrapped by recordProbe() so a single probe failure |
| 16 |
* cannot blank the others or block the support send. Failures emit a |
| 17 |
* marker key `<probe>_error` with a short slug so the server side can |
| 18 |
* tell "no data" from "probe failed." |
| 19 |
* |
| 20 |
* Used by ABJ_404_Solution_FeedbackTransport via composition: |
| 21 |
* $extras = (new ABJ_404_Solution_FeedbackEnvironmentExtras())->collect(); |
| 22 |
* |
| 23 |
* The probe set is documented in detail in |
| 24 |
* docs/bruno-failure-modes-2026-05-13.md (server-side correlation |
| 25 |
* targets) and pinned by tests/FeedbackTransportEnvironmentExtrasTest. |
| 26 |
*/ |
| 27 |
class ABJ_404_Solution_FeedbackEnvironmentExtras { |
| 28 |
|
| 29 |
/** |
| 30 |
* Best-effort diagnostic passthrough for the server's JSON column. The |
| 31 |
* typed columns cover plugin version + WP/PHP/DB identity + content |
| 32 |
* counts, but they cannot cover the operational signals that decide |
| 33 |
* whether a query times out on a real shared host: MySQL memory globals |
| 34 |
* (innodb_buffer_pool_size, tmp_table_size), disk headroom, |
| 35 |
* and PHP SAPI specifics that the server doesn't pre-declare. |
| 36 |
* |
| 37 |
* Every probe is wrapped in recordProbe() so a failed lookup never |
| 38 |
* blocks the support send and surfaces a `<probe>_error` marker |
| 39 |
* with a short server-groupable slug. Filterable via the |
| 40 |
* `abj404_environment_extras` filter so operators can append |
| 41 |
* site-specific diagnostics (or strip fields for privacy) before the |
| 42 |
* payload is sent. |
| 43 |
* |
| 44 |
* @return array<string, mixed> |
| 45 |
*/ |
| 46 |
public function collect(): array { |
| 47 |
$extras = array(); |
| 48 |
|
| 49 |
// MySQL global variables: the binding constraints for slow |
| 50 |
// JOIN / GROUP BY on Bruno-class sites. SHOW GLOBAL VARIABLES |
| 51 |
// is read-only, no plugin tables involved. |
| 52 |
$this->recordProbe($extras, 'mysql_globals', function () { return $this->collectMysqlGlobals(); }, array()); |
| 53 |
|
| 54 |
// MySQL session-variable probe persisted by the staged view-build |
| 55 |
// (DataAccessTrait_ViewBuildSessionEnvProbe). Already covers |
| 56 |
// tmp_table_size, max_heap_table_size, innodb_lock_wait_timeout, |
| 57 |
// wait_timeout, innodb_flush_method, character_set_server. |
| 58 |
// Reading the option instead of re-querying keeps the support |
| 59 |
// request cheap and reflects the state of the most recent build. |
| 60 |
$this->recordProbe($extras, 'mysql_session_probe', function () { return $this->loadViewBuildSessionEnvProbe(); }, array()); |
| 61 |
|
| 62 |
// Disk headroom on the WP uploads directory (where the plugin's |
| 63 |
// debug log and any cron-scratch files land). "Table is full" |
| 64 |
// errors are nearly always disk-quota, not the logical |
| 65 |
// table-full condition. |
| 66 |
$this->recordProbe($extras, 'disk_free_bytes', function () { return $this->diskFreeBytesOrThrow(); }, null); |
| 67 |
$this->recordProbe($extras, 'disk_total_bytes', function () { return $this->diskTotalBytesOrThrow(); }, null); |
| 68 |
|
| 69 |
// PHP runtime identity beyond version. SAPI distinguishes |
| 70 |
// mod_php (per-request fork, fresh memory) from php-fpm |
| 71 |
// (long-lived worker, opcache hot). max_input_vars caps how |
| 72 |
// many POST fields the importer can accept. realpath_cache |
| 73 |
// size matters for sites with many include paths. |
| 74 |
$extras['php_sapi'] = function_exists('php_sapi_name') ? (string)php_sapi_name() : ''; |
| 75 |
$extras['php_memory_peak_bytes'] = function_exists('memory_get_peak_usage') ? (int)memory_get_peak_usage(true) : 0; |
| 76 |
$extras['php_opcache_enabled'] = $this->opcacheEnabled(); |
| 77 |
$extras['php_max_input_vars'] = function_exists('ini_get') ? (int)ini_get('max_input_vars') : 0; |
| 78 |
$extras['php_realpath_cache_size_bytes'] = function_exists('realpath_cache_size') ? (int)realpath_cache_size() : 0; |
| 79 |
|
| 80 |
// Plugin table sizes beyond logsv2 (which has its own typed |
| 81 |
// column). redirects volume and logs_hits rollup size are |
| 82 |
// direct signals for the getRedirectsForViewTempTable.sql |
| 83 |
// perf class. |
| 84 |
$this->recordProbe($extras, 'plugin_tables_bytes', function () { return $this->collectPluginTableSizes(); }, array()); |
| 85 |
|
| 86 |
// View-build freshness signals: when did the rollup last |
| 87 |
// complete, what stage did the most recent build reach, is |
| 88 |
// the rollup stale relative to logsv2? Hand-assembled from |
| 89 |
// plugin options the staged build already writes; no new SQL. |
| 90 |
$this->recordProbe($extras, 'view_build_state', function () { return $this->collectViewBuildState(); }, array()); |
| 91 |
|
| 92 |
// SHOW PROCESSLIST row count. Indicator of shared-host MySQL |
| 93 |
// saturation: a queue of 200+ idle connections explains why |
| 94 |
// the staged build's BEGIN/COMMIT slots wait. Just the count; |
| 95 |
// no connection details (user/host) are emitted. |
| 96 |
$this->recordProbe($extras, 'active_connection_count', function () { return $this->probeActiveConnectionCount(); }, null); |
| 97 |
|
| 98 |
// SHOW INDEX cardinality for the canonical indexes on |
| 99 |
// redirects + logs_hits + logs_hits_preagg. A degraded |
| 100 |
// cardinality (1 row, or NULL after a crash recovery) is a |
| 101 |
// sufficient explanation for a previously-fast JOIN suddenly |
| 102 |
// doing a full table scan. Shape: {table: {index: int}}. |
| 103 |
$this->recordProbe($extras, 'index_cardinality', function () { return $this->probeIndexCardinality(); }, array()); |
| 104 |
|
| 105 |
// Best-effort hosting-class hint parsed from server_software |
| 106 |
// and host-specific environment markers (cPanel, hPanel, |
| 107 |
// Plesk, WP Engine, Kinsta, Pantheon, Flywheel, RunCloud, |
| 108 |
// CloudPanel). Lets server-side group heartbeats by host |
| 109 |
// class retroactively without paying for a deep fingerprint. |
| 110 |
$this->recordProbe($extras, 'hosting_class', function () { return $this->probeHostingClass(); }, array()); |
| 111 |
|
| 112 |
// Object-cache backend NAME, not just the on/off enum already |
| 113 |
// shipped in `object_cache`. Detect Redis / Memcached / APCu |
| 114 |
// / W3TC / LiteSpeed / WP Engine native via known constants |
| 115 |
// + wp_using_ext_object_cache(). Stale-cache reports cluster |
| 116 |
// by backend class. |
| 117 |
$this->recordProbe($extras, 'object_cache_backend', function () { return $this->probeObjectCacheBackend(); }, array()); |
| 118 |
|
| 119 |
// SHOW GLOBAL STATUS counterpart to mysql_globals. Captures |
| 120 |
// runtime symptoms (lock waits, tmp-disk spills, aborted |
| 121 |
// connects, slow queries) that the variables can only |
| 122 |
// bound, never observe. |
| 123 |
$this->recordProbe($extras, 'mysql_status', function () { return $this->probeMysqlStatus(); }, array()); |
| 124 |
|
| 125 |
// DB charset + collation, plus per-column collation on the |
| 126 |
// canonical JOIN keys for redirects (url, canonical_url) and |
| 127 |
// logs_hits (requested_url). Collation drift silently |
| 128 |
// disables index seeks on JOIN: symptom is "fast on staging, |
| 129 |
// slow on prod with identical data." |
| 130 |
$this->recordProbe($extras, 'db_collation', function () { return $this->probeDbCollation(); }, array()); |
| 131 |
|
| 132 |
// WP + PHP timezone identity. Bruno-class sites in non-UTC |
| 133 |
// zones (pt_BR, ja_JP) sometimes show off-by-N-hours bugs |
| 134 |
// in cooldown arithmetic; capturing both lets us diff |
| 135 |
// server time vs WP time vs PHP time after the fact. |
| 136 |
$this->recordProbe($extras, 'timezone', function () { return $this->probeTimezone(); }, array()); |
| 137 |
|
| 138 |
// Install + upgrade history. The single most useful |
| 139 |
// bifurcator for "started after upgrade Tuesday" vs |
| 140 |
// "always broken since install." Read-only from plugin |
| 141 |
// options the upgrade path already writes. |
| 142 |
$this->recordProbe($extras, 'plugin_lifecycle', function () { return $this->probePluginLifecycle(); }, array()); |
| 143 |
|
| 144 |
// Top distinct recurring error signatures from the debug |
| 145 |
// log file over the last 7 days, capped at 5 entries. The |
| 146 |
// triggering error is captured by the report itself; this |
| 147 |
// captures the recurring error which is often different |
| 148 |
// and which the email-on-first-error path would never send. |
| 149 |
$this->recordProbe($extras, 'recent_error_signatures', function () { return $this->probeRecentErrorSignatures(); }, array()); |
| 150 |
|
| 151 |
// opcache detail beyond the on/off enum already shipped |
| 152 |
// in `php_opcache_enabled`. validate_timestamps=0 + |
| 153 |
// revalidate_freq high explains "fresh install still |
| 154 |
// buggy after upgrade" reports where the host serves |
| 155 |
// cached bytecode from the prior version. |
| 156 |
$this->recordProbe($extras, 'opcache_settings', function () { return $this->probeOpcacheSettings(); }, array()); |
| 157 |
|
| 158 |
// open_basedir restriction string (or null when not set). |
| 159 |
// Hardened shared hosts use this to box file access; |
| 160 |
// explains "permission denied" failures on paths the |
| 161 |
// plugin can otherwise write. |
| 162 |
$extras['open_basedir'] = $this->probeOpenBasedir(); |
| 163 |
|
| 164 |
// Multisite identity: is this the main site, what blog |
| 165 |
// and network are we on, is the plugin network-activated? |
| 166 |
// Behavior differs significantly across these axes |
| 167 |
// (network-active vs single-site-active changes hook |
| 168 |
// registration and upgrade scheduling). |
| 169 |
$this->recordProbe($extras, 'multisite_role', function () { return $this->probeMultisiteRole(); }, array()); |
| 170 |
|
| 171 |
// .htaccess writability at the WP home path. When false |
| 172 |
// the plugin's Apache-rule install path cannot succeed |
| 173 |
// and we fall back to the DB-only redirect handler. |
| 174 |
// Differentiates "redirects not firing" reports between |
| 175 |
// "Apache rule never wrote" and "DB handler bug". |
| 176 |
$extras['htaccess_writable'] = $this->probeHtaccessWritable(); |
| 177 |
|
| 178 |
// /tmp filesystem free bytes. Some shared hosts have |
| 179 |
// separate /tmp quotas from the WP install path; tmp |
| 180 |
// exhaustion breaks MySQL tmp tables (Created_tmp_disk_* |
| 181 |
// counter) and PHP file uploads. disk_free_bytes on the |
| 182 |
// uploads dir cannot see this. |
| 183 |
$this->recordProbe($extras, 'tmp_free_bytes', function () { return $this->probeTmpFreeBytesOrThrow(); }, null); |
| 184 |
|
| 185 |
if (function_exists('apply_filters')) { |
| 186 |
$filtered = apply_filters('abj404_environment_extras', $extras); |
| 187 |
if (is_array($filtered)) { |
| 188 |
$extras = $filtered; |
| 189 |
} |
| 190 |
} |
| 191 |
|
| 192 |
return $extras; |
| 193 |
} |
| 194 |
|
| 195 |
/** |
| 196 |
* Run a probe and write either its return value into $extras[$key] |
| 197 |
* on success, or a default value plus a marker $extras[$key.'_error'] |
| 198 |
* on failure. The marker is a short server-groupable slug |
| 199 |
* ('sql_failed', 'wpdb_unavailable', 'fs_unavailable', |
| 200 |
* 'invalid_shape', 'exception:<class>'), not the raw exception |
| 201 |
* message. Exception text can carry PII (paths, user-supplied |
| 202 |
* fragments) and we explicitly do not ship it. The raw message |
| 203 |
* still goes to error_log so the host's local debugging is |
| 204 |
* unaffected. |
| 205 |
* |
| 206 |
* Why marker keys at all: the prior pattern (tryMixedArray returning |
| 207 |
* empty array) could not distinguish "probe succeeded with no data" |
| 208 |
* from "probe failed and we have no signal." Markers make failure |
| 209 |
* explicit so the server side does not have to guess. |
| 210 |
* |
| 211 |
* @param array<string,mixed> $extras |
| 212 |
* @param string $key |
| 213 |
* @param callable $fn |
| 214 |
* @param mixed $default Value written to $extras[$key] on failure |
| 215 |
* so downstream consumers can iterate without per-probe null |
| 216 |
* checks. |
| 217 |
* @return void |
| 218 |
*/ |
| 219 |
private function recordProbe(array &$extras, string $key, callable $fn, $default): void { |
| 220 |
try { |
| 221 |
$value = $fn(); |
| 222 |
} catch (\Throwable $e) { |
| 223 |
$extras[$key] = $default; |
| 224 |
$extras[$key . '_error'] = $this->classifyProbeError($e); |
| 225 |
@error_log('404 Solution: FeedbackEnvironmentExtras probe "' . $key . '" failed: ' . $e->getMessage()); |
| 226 |
return; |
| 227 |
} |
| 228 |
$extras[$key] = $value; |
| 229 |
} |
| 230 |
|
| 231 |
/** |
| 232 |
* Map a thrown probe exception to a short server-groupable slug. |
| 233 |
* Matched on the message rather than the exception class because |
| 234 |
* the probe helpers all throw \RuntimeException. The message is |
| 235 |
* the differentiator. Unmatched throws degrade to |
| 236 |
* 'exception:<ShortClass>' so the slug still carries fingerprint. |
| 237 |
* |
| 238 |
* @param \Throwable $e |
| 239 |
* @return string |
| 240 |
*/ |
| 241 |
private function classifyProbeError(\Throwable $e): string { |
| 242 |
$msg = strtolower((string)$e->getMessage()); |
| 243 |
if (strpos($msg, 'wpdb unavailable') !== false || strpos($msg, 'wpdb missing') !== false) { |
| 244 |
return 'wpdb_unavailable'; |
| 245 |
} |
| 246 |
if (strpos($msg, 'disk_free_space') !== false |
| 247 |
|| strpos($msg, 'disk_total_space') !== false |
| 248 |
|| strpos($msg, 'sys_get_temp_dir') !== false) { |
| 249 |
return 'fs_unavailable'; |
| 250 |
} |
| 251 |
if (strpos($msg, 'invalid shape') !== false |
| 252 |
|| strpos($msg, 'non-array') !== false |
| 253 |
|| strpos($msg, 'unexpected shape') !== false) { |
| 254 |
return 'invalid_shape'; |
| 255 |
} |
| 256 |
if (strpos($msg, 'sql') !== false |
| 257 |
|| strpos($msg, 'mysql') !== false |
| 258 |
|| strpos($msg, 'mariadb') !== false |
| 259 |
|| strpos($msg, 'query') !== false |
| 260 |
|| strpos($msg, 'processlist') !== false |
| 261 |
|| strpos($msg, 'simulated db') !== false |
| 262 |
|| strpos($msg, 'show global') !== false |
| 263 |
|| strpos($msg, 'show index') !== false |
| 264 |
|| strpos($msg, 'show processlist') !== false |
| 265 |
|| strpos($msg, 'information_schema') !== false |
| 266 |
|| strpos($msg, 'all tables failed') !== false |
| 267 |
|| strpos($msg, 'no tables probed') !== false) { |
| 268 |
return 'sql_failed'; |
| 269 |
} |
| 270 |
$shortClass = (new \ReflectionClass($e))->getShortName(); |
| 271 |
return 'exception:' . $shortClass; |
| 272 |
} |
| 273 |
|
| 274 |
/** |
| 275 |
* Pull a fixed set of MySQL global variables relevant to staged |
| 276 |
* view-build / temp-table JOIN performance on Bruno-class hosts. One |
| 277 |
* SHOW GLOBAL VARIABLES query, parameterized name list, suppressed |
| 278 |
* errors so a perms-denied response degrades to an empty map rather |
| 279 |
* than a payload error. |
| 280 |
* |
| 281 |
* @return array<string, mixed> |
| 282 |
*/ |
| 283 |
private function collectMysqlGlobals(): array { |
| 284 |
global $wpdb; |
| 285 |
if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_results')) { |
| 286 |
throw new \RuntimeException('wpdb unavailable for SHOW GLOBAL VARIABLES probe'); |
| 287 |
} |
| 288 |
$names = array( |
| 289 |
'innodb_buffer_pool_size', |
| 290 |
'innodb_log_file_size', |
| 291 |
'innodb_flush_method', |
| 292 |
'innodb_file_per_table', |
| 293 |
'innodb_lock_wait_timeout', |
| 294 |
'tmp_table_size', |
| 295 |
'max_heap_table_size', |
| 296 |
'key_buffer_size', |
| 297 |
'max_allowed_packet', |
| 298 |
'sort_buffer_size', |
| 299 |
'join_buffer_size', |
| 300 |
'max_connections', |
| 301 |
'thread_cache_size', |
| 302 |
'table_open_cache', |
| 303 |
'wait_timeout', |
| 304 |
'interactive_timeout', |
| 305 |
'character_set_server', |
| 306 |
'collation_server', |
| 307 |
'optimizer_switch', |
| 308 |
'sql_mode', |
| 309 |
'long_query_time', |
| 310 |
'slow_query_log', |
| 311 |
'open_files_limit', |
| 312 |
); |
| 313 |
$placeholders = implode(',', array_fill(0, count($names), '%s')); |
| 314 |
$prevSuppress = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : false; |
| 315 |
try { |
| 316 |
$prepared = "SHOW GLOBAL VARIABLES"; |
| 317 |
if (method_exists($wpdb, 'prepare')) { |
| 318 |
// DAO-bypass-approved: SHOW GLOBAL VARIABLES placeholder bind; no plugin-table writes possible. |
| 319 |
$prepared = $wpdb->prepare("SHOW GLOBAL VARIABLES WHERE Variable_name IN ($placeholders)", $names); |
| 320 |
} |
| 321 |
// DAO-bypass-approved: read-only probe of @@GLOBAL; no plugin tables involved. |
| 322 |
$rows = $wpdb->get_results($prepared, ARRAY_A); |
| 323 |
} finally { |
| 324 |
if (method_exists($wpdb, 'suppress_errors')) { |
| 325 |
$wpdb->suppress_errors($prevSuppress); |
| 326 |
} |
| 327 |
} |
| 328 |
|
| 329 |
if (!is_array($rows)) { |
| 330 |
throw new \RuntimeException('SHOW GLOBAL VARIABLES returned non-array'); |
| 331 |
} |
| 332 |
$out = array(); |
| 333 |
foreach ($rows as $row) { |
| 334 |
if (!is_array($row)) { continue; } |
| 335 |
$name = ''; |
| 336 |
$value = ''; |
| 337 |
foreach ($row as $k => $v) { |
| 338 |
$klow = strtolower((string)$k); |
| 339 |
if ($klow === 'variable_name' && is_scalar($v)) { $name = strtolower((string)$v); } |
| 340 |
if ($klow === 'value' && is_scalar($v)) { $value = (string)$v; } |
| 341 |
} |
| 342 |
if ($name === '') { continue; } |
| 343 |
// Coerce numeric-looking values so the server-side JSON sort |
| 344 |
// is meaningful (otherwise 9 sorts after 100 lexically). |
| 345 |
if (is_numeric($value) && strpos($value, '.') === false) { |
| 346 |
$out[$name] = (int)$value; |
| 347 |
} elseif (is_numeric($value)) { |
| 348 |
$out[$name] = (float)$value; |
| 349 |
} else { |
| 350 |
$out[$name] = $value; |
| 351 |
} |
| 352 |
} |
| 353 |
return $out; |
| 354 |
} |
| 355 |
|
| 356 |
/** |
| 357 |
* Read the persisted session-variable probe written at S1 entry by |
| 358 |
* the staged view-build pipeline (DataAccessTrait_ViewBuildSessionEnvProbe). |
| 359 |
* Reflects the most recent build's MySQL session settings without |
| 360 |
* paying for a fresh SHOW SESSION VARIABLES on the support-request path. |
| 361 |
* |
| 362 |
* @return array<string, mixed> |
| 363 |
*/ |
| 364 |
private function loadViewBuildSessionEnvProbe(): array { |
| 365 |
if (!function_exists('get_option')) { |
| 366 |
return array(); |
| 367 |
} |
| 368 |
$opt = get_option('abj404_view_build_session_env_probe', array()); |
| 369 |
if (!is_array($opt)) { |
| 370 |
return array(); |
| 371 |
} |
| 372 |
$out = array(); |
| 373 |
foreach ($opt as $k => $v) { |
| 374 |
if (is_string($k)) { |
| 375 |
$out[$k] = $v; |
| 376 |
} |
| 377 |
} |
| 378 |
return $out; |
| 379 |
} |
| 380 |
|
| 381 |
/** |
| 382 |
* Free bytes available on the WP uploads directory's filesystem. Used |
| 383 |
* to triage "Table is full" reports (the logical table-full condition |
| 384 |
* is rare, disk quota is common). Throws when disk_free_space() is |
| 385 |
* disabled (open_basedir, hardened hosts) so the caller's tryInt |
| 386 |
* wrapper records null rather than a misleading zero. |
| 387 |
* |
| 388 |
* @return int |
| 389 |
*/ |
| 390 |
private function diskFreeBytesOrThrow(): int { |
| 391 |
if (!function_exists('disk_free_space')) { |
| 392 |
throw new \RuntimeException('disk_free_space unavailable'); |
| 393 |
} |
| 394 |
$dir = $this->supportDiagnosticsDirectory(); |
| 395 |
$v = @disk_free_space($dir); |
| 396 |
if ($v === false) { |
| 397 |
throw new \RuntimeException('disk_free_space returned false for ' . $dir); |
| 398 |
} |
| 399 |
return (int)$v; |
| 400 |
} |
| 401 |
|
| 402 |
/** |
| 403 |
* Total bytes on the same filesystem. Combined with disk_free_bytes, |
| 404 |
* lets the server-side report show "8% free" rather than a raw byte |
| 405 |
* count that is hard to interpret across hosts. |
| 406 |
* |
| 407 |
* @return int |
| 408 |
*/ |
| 409 |
private function diskTotalBytesOrThrow(): int { |
| 410 |
if (!function_exists('disk_total_space')) { |
| 411 |
throw new \RuntimeException('disk_total_space unavailable'); |
| 412 |
} |
| 413 |
$dir = $this->supportDiagnosticsDirectory(); |
| 414 |
$v = @disk_total_space($dir); |
| 415 |
if ($v === false) { |
| 416 |
throw new \RuntimeException('disk_total_space returned false for ' . $dir); |
| 417 |
} |
| 418 |
return (int)$v; |
| 419 |
} |
| 420 |
|
| 421 |
/** |
| 422 |
* Best directory to probe for the plugin's filesystem headroom. The |
| 423 |
* uploads dir is the most useful target (the debug log and any |
| 424 |
* cron-scratch files land there), but it may not be writable in |
| 425 |
* locked-down installs. Falls back to ABSPATH and finally __DIR__. |
| 426 |
* |
| 427 |
* @return string |
| 428 |
*/ |
| 429 |
private function supportDiagnosticsDirectory(): string { |
| 430 |
if (function_exists('wp_upload_dir')) { |
| 431 |
$info = wp_upload_dir(null, false); |
| 432 |
if (is_array($info) && isset($info['basedir']) && is_string($info['basedir']) && $info['basedir'] !== '') { |
| 433 |
return $info['basedir']; |
| 434 |
} |
| 435 |
} |
| 436 |
if (defined('ABSPATH') && is_string(ABSPATH) && ABSPATH !== '') { |
| 437 |
return ABSPATH; |
| 438 |
} |
| 439 |
return __DIR__; |
| 440 |
} |
| 441 |
|
| 442 |
/** @return bool */ |
| 443 |
private function opcacheEnabled(): bool { |
| 444 |
if (function_exists('opcache_get_status')) { |
| 445 |
$st = @opcache_get_status(false); |
| 446 |
if (is_array($st) && isset($st['opcache_enabled'])) { |
| 447 |
return (bool)$st['opcache_enabled']; |
| 448 |
} |
| 449 |
} |
| 450 |
if (function_exists('ini_get')) { |
| 451 |
$v = ini_get('opcache.enable'); |
| 452 |
if ($v === false) { |
| 453 |
return false; |
| 454 |
} |
| 455 |
return ((int)$v === 1 || strtolower((string)$v) === 'on'); |
| 456 |
} |
| 457 |
return false; |
| 458 |
} |
| 459 |
|
| 460 |
/** |
| 461 |
* Size of plugin-owned tables beyond logsv2 (which has its own typed |
| 462 |
* column). The redirects-tab perf bug is bounded by the |
| 463 |
* redirects/logs_hits volume, not logsv2, so shipping both lets the |
| 464 |
* report rank reports by the right axis. |
| 465 |
* |
| 466 |
* Per-table shape: {data_length: int, index_length: int, data_free: int, |
| 467 |
* bytes: int}. `data_free` is the fragmentation indicator (bytes |
| 468 |
* allocated to the file but not in use); a fragmentation ratio of |
| 469 |
* data_free / (data_length + index_length) over ~0.3 explains the |
| 470 |
* "tables are 200 MB but only 50 MB of data" long-tail slowness. |
| 471 |
* `bytes` is the legacy combined data+index size kept for backward |
| 472 |
* compatibility with consumers that pre-dated the data_free split. |
| 473 |
* |
| 474 |
* @return array<string, array<string, int>> |
| 475 |
*/ |
| 476 |
private function collectPluginTableSizes(): array { |
| 477 |
global $wpdb; |
| 478 |
if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_results') || !method_exists($wpdb, 'get_row')) { |
| 479 |
throw new \RuntimeException('wpdb unavailable for plugin_tables_bytes probe'); |
| 480 |
} |
| 481 |
$prefix = (isset($wpdb->prefix) && is_string($wpdb->prefix)) ? $wpdb->prefix : 'wp_'; |
| 482 |
$candidates = array( |
| 483 |
'redirects' => $prefix . 'abj404_redirects', |
| 484 |
'logs_hits' => $prefix . 'abj404_logs_hits', |
| 485 |
'logs_hits_preagg' => $prefix . 'abj404_logs_hits_preagg', |
| 486 |
'permalink_cache' => $prefix . 'abj404_permalink_cache', |
| 487 |
'spelling_cache' => $prefix . 'abj404_spelling_cache', |
| 488 |
'lookup' => $prefix . 'abj404_lookup', |
| 489 |
); |
| 490 |
$prevSuppress = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : false; |
| 491 |
$out = array(); |
| 492 |
$errors = 0; |
| 493 |
$attempted = 0; |
| 494 |
foreach ($candidates as $key => $table) { |
| 495 |
$attempted++; |
| 496 |
try { |
| 497 |
if (!method_exists($wpdb, 'prepare')) { continue; } |
| 498 |
// DAO-bypass-approved: information_schema metadata probe placeholder bind; no plugin-table writes. |
| 499 |
$prepared = $wpdb->prepare( |
| 500 |
'SELECT data_length, index_length, data_free ' |
| 501 |
. 'FROM information_schema.TABLES ' |
| 502 |
. 'WHERE table_schema = DATABASE() AND table_name = %s', |
| 503 |
$table |
| 504 |
); |
| 505 |
if ($prepared === null) { continue; } |
| 506 |
// DAO-bypass-approved: information_schema probe; no plugin tables touched, no error class repair would apply. |
| 507 |
$row = $wpdb->get_row($prepared, ARRAY_A); |
| 508 |
if (!is_array($row)) { continue; } |
| 509 |
$dl = 0; $il = 0; $df = 0; |
| 510 |
foreach ($row as $col => $val) { |
| 511 |
if (!is_scalar($val) || !is_numeric($val)) { continue; } |
| 512 |
$clow = strtolower((string)$col); |
| 513 |
if ($clow === 'data_length') { $dl = (int)$val; } |
| 514 |
if ($clow === 'index_length') { $il = (int)$val; } |
| 515 |
if ($clow === 'data_free') { $df = (int)$val; } |
| 516 |
} |
| 517 |
$out[$key] = array( |
| 518 |
'data_length' => $dl, |
| 519 |
'index_length' => $il, |
| 520 |
'data_free' => $df, |
| 521 |
'bytes' => $dl + $il, |
| 522 |
); |
| 523 |
} catch (\Throwable $e) { |
| 524 |
// allow-silent-catch: per-table probe is best-effort; a missing-table or permissions error must not abort the whole map. Aggregated failure is rethrown after the loop when ALL attempts failed (see $errors check below) so the recordProbe wrapper can write the marker key. |
| 525 |
@error_log('404 Solution: collectPluginTableSizes probe failed for ' . $table . ': ' . $e->getMessage()); |
| 526 |
$errors++; |
| 527 |
} |
| 528 |
} |
| 529 |
if (method_exists($wpdb, 'suppress_errors')) { |
| 530 |
$wpdb->suppress_errors($prevSuppress); |
| 531 |
} |
| 532 |
if ($errors === $attempted && empty($out)) { |
| 533 |
throw new \RuntimeException('plugin_tables_bytes: all tables failed SQL probe'); |
| 534 |
} |
| 535 |
return $out; |
| 536 |
} |
| 537 |
|
| 538 |
/** |
| 539 |
* View-build freshness signals: when did the rollup last complete, |
| 540 |
* what stage did the most recent build reach, and is the rollup |
| 541 |
* stale relative to logsv2? Hand-assembled from plugin options the |
| 542 |
* staged build already writes; no new SQL. |
| 543 |
* |
| 544 |
* @return array<string, int> |
| 545 |
*/ |
| 546 |
private function collectViewBuildState(): array { |
| 547 |
if (!function_exists('get_option')) { |
| 548 |
return array(); |
| 549 |
} |
| 550 |
$out = array(); |
| 551 |
$optMap = array( |
| 552 |
'last_build_completed_at' => 'abj404_view_build_last_completed_at', |
| 553 |
'last_build_started_at' => 'abj404_view_build_last_started_at', |
| 554 |
'last_build_stage' => 'abj404_view_build_last_stage', |
| 555 |
'last_build_failure_at' => 'abj404_view_build_last_failure_at', |
| 556 |
'logs_hits_max_log_id' => 'abj404_logs_hits_max_log_id', |
| 557 |
); |
| 558 |
foreach ($optMap as $outKey => $optName) { |
| 559 |
$v = get_option($optName, null); |
| 560 |
if (is_scalar($v)) { |
| 561 |
$out[$outKey] = is_numeric($v) ? (int)$v : 0; |
| 562 |
} |
| 563 |
} |
| 564 |
return $out; |
| 565 |
} |
| 566 |
|
| 567 |
/** |
| 568 |
* Row count from SHOW PROCESSLIST. Cheap on a shared host (returns |
| 569 |
* the current request's view of connection saturation) and a strong |
| 570 |
* leading indicator for "the BEGIN/COMMIT in the staged build is |
| 571 |
* waiting because there are 200 other queries in flight". Only the |
| 572 |
* row count is emitted; user/host/info columns are dropped to avoid |
| 573 |
* PII leakage from other tenants on the same MySQL instance. |
| 574 |
* |
| 575 |
* Throws when the probe genuinely cannot complete (no $wpdb, query |
| 576 |
* failed) so the caller's tryInt wrapper records null rather than |
| 577 |
* a misleading zero. |
| 578 |
* |
| 579 |
* @return int |
| 580 |
*/ |
| 581 |
private function probeActiveConnectionCount(): int { |
| 582 |
global $wpdb; |
| 583 |
if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_results')) { |
| 584 |
throw new \RuntimeException('wpdb unavailable'); |
| 585 |
} |
| 586 |
$prevSuppress = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : false; |
| 587 |
$rows = null; |
| 588 |
try { |
| 589 |
// DAO-bypass-approved: read-only probe of @@PROCESSLIST; no plugin tables involved. |
| 590 |
$rows = $wpdb->get_results('SHOW PROCESSLIST', ARRAY_A); |
| 591 |
} catch (\Throwable $e) { |
| 592 |
// allow-silent-catch: probe is best-effort; rethrow after restoring suppress so the outer tryInt records null |
| 593 |
@error_log('404 Solution: probeActiveConnectionCount failed: ' . $e->getMessage()); |
| 594 |
$rows = null; |
| 595 |
} |
| 596 |
if (method_exists($wpdb, 'suppress_errors')) { |
| 597 |
$wpdb->suppress_errors($prevSuppress); |
| 598 |
} |
| 599 |
if (!is_array($rows)) { |
| 600 |
throw new \RuntimeException('processlist probe failed'); |
| 601 |
} |
| 602 |
return count($rows); |
| 603 |
} |
| 604 |
|
| 605 |
/** |
| 606 |
* Per-index cardinality for the canonical indexes on the JOIN-hot |
| 607 |
* plugin tables. Output shape: |
| 608 |
* { redirects: {idx_url_disabled_status: int, idx_canonical_url: int, ...}, |
| 609 |
* logs_hits: {requested_url: int, ...}, |
| 610 |
* logs_hits_preagg: {...} } |
| 611 |
* |
| 612 |
* Each table is probed in its own SHOW INDEX statement, isolated in |
| 613 |
* try/catch so a missing table (rebuild race, repair pending) does |
| 614 |
* not blank the whole map. Only the Cardinality value is captured; |
| 615 |
* the rest of the SHOW INDEX columns are not emitted. |
| 616 |
* |
| 617 |
* @return array<string, array<string, int>> |
| 618 |
*/ |
| 619 |
private function probeIndexCardinality(): array { |
| 620 |
global $wpdb; |
| 621 |
if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_results')) { |
| 622 |
throw new \RuntimeException('wpdb unavailable for index_cardinality probe'); |
| 623 |
} |
| 624 |
$prefix = (isset($wpdb->prefix) && is_string($wpdb->prefix)) ? $wpdb->prefix : 'wp_'; |
| 625 |
$candidates = array( |
| 626 |
'redirects' => $prefix . 'abj404_redirects', |
| 627 |
'logs_hits' => $prefix . 'abj404_logs_hits', |
| 628 |
'logs_hits_preagg' => $prefix . 'abj404_logs_hits_preagg', |
| 629 |
'logsv2' => $prefix . 'abj404_logsv2', |
| 630 |
); |
| 631 |
$prevSuppress = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : false; |
| 632 |
$out = array(); |
| 633 |
$errors = 0; |
| 634 |
$attempted = 0; |
| 635 |
foreach ($candidates as $key => $table) { |
| 636 |
$attempted++; |
| 637 |
try { |
| 638 |
$prepared = 'SHOW INDEX FROM `' . $table . '`'; |
| 639 |
if (method_exists($wpdb, 'prepare')) { |
| 640 |
// DAO-bypass-approved: SHOW INDEX metadata probe placeholder bind; no plugin-table writes possible. |
| 641 |
$prepared = $wpdb->prepare($prepared); |
| 642 |
} |
| 643 |
if ($prepared === null || $prepared === '') { |
| 644 |
continue; |
| 645 |
} |
| 646 |
// DAO-bypass-approved: SHOW INDEX is read-only metadata; no plugin-table writes possible. |
| 647 |
$rows = $wpdb->get_results($prepared, ARRAY_A); |
| 648 |
if (!is_array($rows)) { |
| 649 |
continue; |
| 650 |
} |
| 651 |
$byIndex = array(); |
| 652 |
foreach ($rows as $row) { |
| 653 |
if (!is_array($row)) { continue; } |
| 654 |
$idxName = ''; |
| 655 |
$card = null; |
| 656 |
foreach ($row as $col => $val) { |
| 657 |
$clow = strtolower((string)$col); |
| 658 |
if ($clow === 'key_name' && is_scalar($val)) { $idxName = (string)$val; } |
| 659 |
if ($clow === 'cardinality' && is_scalar($val) && is_numeric($val)) { $card = (int)$val; } |
| 660 |
} |
| 661 |
if ($idxName === '' || $card === null) { continue; } |
| 662 |
if (!isset($byIndex[$idxName]) || $card > $byIndex[$idxName]) { |
| 663 |
$byIndex[$idxName] = $card; |
| 664 |
} |
| 665 |
} |
| 666 |
if (!empty($byIndex)) { |
| 667 |
$out[$key] = $byIndex; |
| 668 |
} |
| 669 |
} catch (\Throwable $e) { |
| 670 |
// allow-silent-catch: per-table probe is best-effort; a missing-table or permissions error must not abort the whole map. Aggregated failure is rethrown after the loop when ALL attempts failed (see $errors check below) so the recordProbe wrapper can write the marker key. |
| 671 |
@error_log('404 Solution: probeIndexCardinality failed for ' . $table . ': ' . $e->getMessage()); |
| 672 |
$errors++; |
| 673 |
} |
| 674 |
} |
| 675 |
if (method_exists($wpdb, 'suppress_errors')) { |
| 676 |
$wpdb->suppress_errors($prevSuppress); |
| 677 |
} |
| 678 |
if ($errors === $attempted && empty($out)) { |
| 679 |
throw new \RuntimeException('index_cardinality: all tables failed SHOW INDEX probe'); |
| 680 |
} |
| 681 |
return $out; |
| 682 |
} |
| 683 |
|
| 684 |
/** |
| 685 |
* Best-effort hosting-class hint. Parses well-known markers from |
| 686 |
* server_software + per-host environment vars + per-host PHP |
| 687 |
* constants. Returns a small object so the server side can |
| 688 |
* distinguish "WP Engine" from "Kinsta" without re-parsing strings. |
| 689 |
* |
| 690 |
* No PII: only matched markers are returned. server_software is NOT |
| 691 |
* echoed wholesale; it may include a hostname. |
| 692 |
* |
| 693 |
* @return array<string, mixed> |
| 694 |
*/ |
| 695 |
private function probeHostingClass(): array { |
| 696 |
$out = array( |
| 697 |
'host' => 'unknown', |
| 698 |
'panel' => 'unknown', |
| 699 |
'matched_marker' => '', |
| 700 |
); |
| 701 |
$sw = ''; |
| 702 |
if (isset($_SERVER['SERVER_SOFTWARE']) && is_scalar($_SERVER['SERVER_SOFTWARE'])) { |
| 703 |
$sw = strtolower((string)$_SERVER['SERVER_SOFTWARE']); |
| 704 |
} |
| 705 |
// Webserver class only (no version, no hostname). |
| 706 |
if (strpos($sw, 'apache') !== false) { $out['server_class'] = 'apache'; } |
| 707 |
elseif (strpos($sw, 'nginx') !== false) { $out['server_class'] = 'nginx'; } |
| 708 |
elseif (strpos($sw, 'litespeed') !== false){ $out['server_class'] = 'litespeed'; } |
| 709 |
elseif (strpos($sw, 'iis') !== false) { $out['server_class'] = 'iis'; } |
| 710 |
else { $out['server_class'] = ($sw === '' ? 'unknown' : 'other'); } |
| 711 |
|
| 712 |
// Managed-host markers: each host publishes a distinctive |
| 713 |
// constant or environment variable. |
| 714 |
$managedHostChecks = array( |
| 715 |
'wp_engine' => array('const' => array('WPE_APIKEY', 'WPE_PLUGIN_DIR'), 'env' => array('IS_WPE')), |
| 716 |
'kinsta' => array('const' => array('KINSTA_CACHE_ZONE'), 'env' => array('KINSTA_SERVICE_NAME')), |
| 717 |
'pantheon' => array('const' => array('PANTHEON_ENVIRONMENT'), 'env' => array('PANTHEON_ENVIRONMENT')), |
| 718 |
'flywheel' => array('const' => array('FLYWHEEL_CONFIG_DIR', 'FLYWHEEL_PLUGIN_DIR'), 'env' => array()), |
| 719 |
'pressable' => array('const' => array('PRESSABLE_VERSION'), 'env' => array()), |
| 720 |
'siteground' => array('const' => array('SG_OPTIMIZER_VERSION'), 'env' => array()), |
| 721 |
'wordpress_com' => array('const' => array('IS_ATOMIC', 'IS_WPCOM'), 'env' => array()), |
| 722 |
'cloudways' => array('const' => array(), 'env' => array('cw_allowed_ip')), |
| 723 |
); |
| 724 |
foreach ($managedHostChecks as $hostKey => $checks) { |
| 725 |
foreach ((array)$checks['const'] as $c) { |
| 726 |
if (defined($c)) { |
| 727 |
$out['host'] = $hostKey; |
| 728 |
$out['matched_marker'] = 'const:' . $c; |
| 729 |
break 2; |
| 730 |
} |
| 731 |
} |
| 732 |
foreach ((array)$checks['env'] as $e) { |
| 733 |
if (getenv($e) !== false) { |
| 734 |
$out['host'] = $hostKey; |
| 735 |
$out['matched_marker'] = 'env:' . $e; |
| 736 |
break 2; |
| 737 |
} |
| 738 |
} |
| 739 |
} |
| 740 |
|
| 741 |
// Control-panel markers: cPanel / hPanel / Plesk / DirectAdmin / |
| 742 |
// RunCloud / CloudPanel. These are independent of the managed-host |
| 743 |
// class above: a cPanel site might also be on SiteGround. |
| 744 |
$panelChecks = array( |
| 745 |
'cpanel' => array('env' => array('CPANEL'), 'path' => array('/usr/local/cpanel')), |
| 746 |
'hpanel' => array('env' => array('HOSTINGER'), 'path' => array('/usr/local/hostinger')), |
| 747 |
'plesk' => array('env' => array('PLESK_ADMIN_PASSWORD'), 'path' => array('/usr/local/psa', '/opt/psa')), |
| 748 |
'directadmin' => array('env' => array(), 'path' => array('/usr/local/directadmin')), |
| 749 |
'runcloud' => array('env' => array(), 'path' => array('/etc/runcloud')), |
| 750 |
'cloudpanel' => array('env' => array(), 'path' => array('/home/clp')), |
| 751 |
); |
| 752 |
foreach ($panelChecks as $panelKey => $checks) { |
| 753 |
foreach ((array)$checks['env'] as $e) { |
| 754 |
if (getenv($e) !== false) { |
| 755 |
$out['panel'] = $panelKey; |
| 756 |
if ($out['matched_marker'] === '') { |
| 757 |
$out['matched_marker'] = 'env:' . $e; |
| 758 |
} |
| 759 |
break 2; |
| 760 |
} |
| 761 |
} |
| 762 |
foreach ((array)$checks['path'] as $p) { |
| 763 |
if (is_dir($p)) { |
| 764 |
$out['panel'] = $panelKey; |
| 765 |
if ($out['matched_marker'] === '') { |
| 766 |
$out['matched_marker'] = 'path:' . $p; |
| 767 |
} |
| 768 |
break 2; |
| 769 |
} |
| 770 |
} |
| 771 |
} |
| 772 |
|
| 773 |
return $out; |
| 774 |
} |
| 775 |
|
| 776 |
/** |
| 777 |
* Object-cache backend NAME. The base payload's `object_cache` enum |
| 778 |
* answers "external or default"; this answers "external WHAT": Redis |
| 779 |
* (predis vs phpredis vs Redis Object Cache plugin), Memcached, |
| 780 |
* APCu, W3TC, LiteSpeed, WP Engine native, Pantheon, etc. |
| 781 |
* |
| 782 |
* @return array<string, mixed> |
| 783 |
*/ |
| 784 |
private function probeObjectCacheBackend(): array { |
| 785 |
$out = array( |
| 786 |
'using_ext_cache' => false, |
| 787 |
'backend' => 'unknown', |
| 788 |
'backend_detail' => '', |
| 789 |
); |
| 790 |
if (function_exists('wp_using_ext_object_cache')) { |
| 791 |
$out['using_ext_cache'] = (bool)wp_using_ext_object_cache(); |
| 792 |
} |
| 793 |
// Known constants/classes/extensions from popular object-cache |
| 794 |
// drop-ins. Each tuple is (name, type, marker): the first match |
| 795 |
// wins so a Redis Object Cache Pro install is not also tagged |
| 796 |
// as plain Redis. |
| 797 |
$checks = array( |
| 798 |
array('redis_object_cache_pro', 'const', 'WP_REDIS_VERSION'), |
| 799 |
array('redis_object_cache_pro', 'class', 'RedisCachePro\\Plugin'), |
| 800 |
array('redis_object_cache', 'class', 'WP_Object_Cache'), |
| 801 |
array('memcached', 'class', 'Memcached'), |
| 802 |
array('apcu', 'ext', 'apcu'), |
| 803 |
array('w3_total_cache', 'const', 'W3TC_VERSION'), |
| 804 |
array('litespeed_cache', 'const', 'LSCWP_DIR'), |
| 805 |
array('wp_engine_native', 'const', 'WPE_APIKEY'), |
| 806 |
array('pantheon', 'const', 'PANTHEON_ENVIRONMENT'), |
| 807 |
); |
| 808 |
foreach ($checks as $check) { |
| 809 |
list($name, $type, $marker) = $check; |
| 810 |
if ($type === 'const' && defined($marker)) { |
| 811 |
$out['backend'] = $name; |
| 812 |
$out['backend_detail'] = 'const:' . $marker; |
| 813 |
return $out; |
| 814 |
} |
| 815 |
if ($type === 'class' && class_exists($marker, false)) { |
| 816 |
$out['backend'] = $name; |
| 817 |
$out['backend_detail'] = 'class:' . $marker; |
| 818 |
return $out; |
| 819 |
} |
| 820 |
if ($type === 'ext' && extension_loaded($marker)) { |
| 821 |
$out['backend'] = $name; |
| 822 |
$out['backend_detail'] = 'ext:' . $marker; |
| 823 |
return $out; |
| 824 |
} |
| 825 |
} |
| 826 |
// Default WP object cache used in-memory per request. |
| 827 |
if (!$out['using_ext_cache']) { |
| 828 |
$out['backend'] = 'default'; |
| 829 |
$out['backend_detail'] = 'wp_object_cache:in_memory'; |
| 830 |
} |
| 831 |
return $out; |
| 832 |
} |
| 833 |
|
| 834 |
/** |
| 835 |
* SHOW GLOBAL STATUS counterpart to mysql_globals. The variables tell |
| 836 |
* us what the server is CONFIGURED to allow; the status counters tell |
| 837 |
* us what is actually HAPPENING. Counters that have ticked up since |
| 838 |
* boot are the strongest proximate-cause signal: lock-wait pile-ups, |
| 839 |
* tmp-disk spills, aborted connects, slow queries. |
| 840 |
* |
| 841 |
* One SHOW GLOBAL STATUS query, parameterized name list, suppressed |
| 842 |
* errors so a perms-denied response degrades to an empty map rather |
| 843 |
* than a payload error. |
| 844 |
* |
| 845 |
* @return array<string, int> |
| 846 |
*/ |
| 847 |
private function probeMysqlStatus(): array { |
| 848 |
global $wpdb; |
| 849 |
if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_results')) { |
| 850 |
throw new \RuntimeException('wpdb unavailable for SHOW GLOBAL STATUS probe'); |
| 851 |
} |
| 852 |
$names = array( |
| 853 |
'Innodb_buffer_pool_pages_dirty', |
| 854 |
'Innodb_buffer_pool_pages_total', |
| 855 |
'Innodb_row_lock_waits', |
| 856 |
'Innodb_row_lock_time_avg', |
| 857 |
'Innodb_deadlocks', |
| 858 |
'Threads_running', |
| 859 |
'Threads_connected', |
| 860 |
'Aborted_connects', |
| 861 |
'Aborted_clients', |
| 862 |
'Created_tmp_disk_tables', |
| 863 |
'Created_tmp_tables', |
| 864 |
'Slow_queries', |
| 865 |
'Table_locks_waited', |
| 866 |
'Open_tables', |
| 867 |
'Opened_tables', |
| 868 |
'Uptime', |
| 869 |
); |
| 870 |
$placeholders = implode(',', array_fill(0, count($names), '%s')); |
| 871 |
$prevSuppress = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : false; |
| 872 |
try { |
| 873 |
$prepared = 'SHOW GLOBAL STATUS'; |
| 874 |
if (method_exists($wpdb, 'prepare')) { |
| 875 |
// DAO-bypass-approved: SHOW GLOBAL STATUS placeholder bind; no plugin-table writes possible. |
| 876 |
$prepared = $wpdb->prepare("SHOW GLOBAL STATUS WHERE Variable_name IN ($placeholders)", $names); |
| 877 |
} |
| 878 |
// DAO-bypass-approved: read-only probe of @@GLOBAL_STATUS; no plugin tables involved. |
| 879 |
$rows = $wpdb->get_results($prepared, ARRAY_A); |
| 880 |
} finally { |
| 881 |
if (method_exists($wpdb, 'suppress_errors')) { |
| 882 |
$wpdb->suppress_errors($prevSuppress); |
| 883 |
} |
| 884 |
} |
| 885 |
if (!is_array($rows)) { |
| 886 |
throw new \RuntimeException('SHOW GLOBAL STATUS returned non-array'); |
| 887 |
} |
| 888 |
$out = array(); |
| 889 |
foreach ($rows as $row) { |
| 890 |
if (!is_array($row)) { continue; } |
| 891 |
$name = ''; |
| 892 |
$value = ''; |
| 893 |
foreach ($row as $k => $v) { |
| 894 |
$klow = strtolower((string)$k); |
| 895 |
if ($klow === 'variable_name' && is_scalar($v)) { $name = strtolower((string)$v); } |
| 896 |
if ($klow === 'value' && is_scalar($v)) { $value = (string)$v; } |
| 897 |
} |
| 898 |
if ($name === '') { continue; } |
| 899 |
if (is_numeric($value)) { |
| 900 |
$out[$name] = (int)$value; |
| 901 |
} |
| 902 |
} |
| 903 |
return $out; |
| 904 |
} |
| 905 |
|
| 906 |
/** |
| 907 |
* DB-level + per-column collation for the JOIN-hot URL columns on |
| 908 |
* `abj404_redirects` and `abj404_logs_hits`. Collation drift between |
| 909 |
* the two columns disables the index seek silently: MySQL falls back |
| 910 |
* to a full-scan ON the un-joined column. Capturing both lets the |
| 911 |
* server side classify "fast on staging, slow on prod" reports by |
| 912 |
* the cause that is invisible from the SHOW CREATE TABLE output. |
| 913 |
* |
| 914 |
* Shape: |
| 915 |
* { db_charset: string, db_collate: string, |
| 916 |
* columns: { '{prefix}abj404_redirects.url': string, |
| 917 |
* '{prefix}abj404_redirects.canonical_url': string, |
| 918 |
* '{prefix}abj404_logs_hits.requested_url': string } } |
| 919 |
* |
| 920 |
* @return array<string, mixed> |
| 921 |
*/ |
| 922 |
private function probeDbCollation(): array { |
| 923 |
$out = array( |
| 924 |
'db_charset' => defined('DB_CHARSET') && is_string(DB_CHARSET) ? DB_CHARSET : '', |
| 925 |
'db_collate' => defined('DB_COLLATE') && is_string(DB_COLLATE) ? DB_COLLATE : '', |
| 926 |
'columns' => array(), |
| 927 |
); |
| 928 |
global $wpdb; |
| 929 |
if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_results') || !method_exists($wpdb, 'get_var')) { |
| 930 |
throw new \RuntimeException('wpdb unavailable for db_collation probe'); |
| 931 |
} |
| 932 |
$prefix = (isset($wpdb->prefix) && is_string($wpdb->prefix)) ? $wpdb->prefix : 'wp_'; |
| 933 |
$targets = array( |
| 934 |
$prefix . 'abj404_redirects' => array('url', 'canonical_url'), |
| 935 |
$prefix . 'abj404_logs_hits' => array('requested_url'), |
| 936 |
$prefix . 'abj404_logsv2' => array('url'), |
| 937 |
); |
| 938 |
$prevSuppress = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : false; |
| 939 |
$errors = 0; |
| 940 |
$attempted = 0; |
| 941 |
foreach ($targets as $table => $cols) { |
| 942 |
foreach ($cols as $col) { |
| 943 |
$attempted++; |
| 944 |
try { |
| 945 |
if (!method_exists($wpdb, 'prepare')) { continue; } |
| 946 |
// DAO-bypass-approved: information_schema collation probe placeholder bind; no plugin-table writes. |
| 947 |
$prepared = $wpdb->prepare( |
| 948 |
'SELECT COLLATION_NAME ' |
| 949 |
. 'FROM information_schema.COLUMNS ' |
| 950 |
. 'WHERE table_schema = DATABASE() AND table_name = %s AND column_name = %s', |
| 951 |
$table, |
| 952 |
$col |
| 953 |
); |
| 954 |
if ($prepared === null) { continue; } |
| 955 |
// DAO-bypass-approved: information_schema metadata probe; no plugin-table writes. |
| 956 |
$v = $wpdb->get_var($prepared); |
| 957 |
if (is_scalar($v) && (string)$v !== '') { |
| 958 |
$out['columns'][$table . '.' . $col] = (string)$v; |
| 959 |
} |
| 960 |
} catch (\Throwable $e) { |
| 961 |
// allow-silent-catch: per-column probe is best-effort; missing-table / permissions errors must not abort the whole map. Aggregated failure is rethrown after the loop when ALL attempts failed (see $errors check below) so the recordProbe wrapper can write the marker key. |
| 962 |
@error_log('404 Solution: probeDbCollation probe failed for ' . $table . '.' . $col . ': ' . $e->getMessage()); |
| 963 |
$errors++; |
| 964 |
} |
| 965 |
} |
| 966 |
} |
| 967 |
if (method_exists($wpdb, 'suppress_errors')) { |
| 968 |
$wpdb->suppress_errors($prevSuppress); |
| 969 |
} |
| 970 |
if ($errors === $attempted && empty($out['columns'])) { |
| 971 |
throw new \RuntimeException('db_collation: all per-column SQL probes failed'); |
| 972 |
} |
| 973 |
return $out; |
| 974 |
} |
| 975 |
|
| 976 |
/** |
| 977 |
* Timezone identity for the WP install, PHP runtime, and OS. The |
| 978 |
* canonical "off-by-N-hours" cron-window bug class is when WP thinks |
| 979 |
* it is in pt_BR while PHP is in UTC; capturing all three lets us |
| 980 |
* detect drift retroactively. |
| 981 |
* |
| 982 |
* @return array<string, mixed> |
| 983 |
*/ |
| 984 |
private function probeTimezone(): array { |
| 985 |
$out = array( |
| 986 |
'wp_timezone' => '', |
| 987 |
'wp_gmt_offset' => 0, |
| 988 |
'php_timezone' => '', |
| 989 |
'server_utc_offset_seconds' => 0, |
| 990 |
); |
| 991 |
if (function_exists('get_option')) { |
| 992 |
$tz = get_option('timezone_string', ''); |
| 993 |
if (is_scalar($tz)) { $out['wp_timezone'] = (string)$tz; } |
| 994 |
$off = get_option('gmt_offset', 0); |
| 995 |
if (is_scalar($off)) { $out['wp_gmt_offset'] = (int)round((float)$off * 3600); } |
| 996 |
} |
| 997 |
if (function_exists('date_default_timezone_get')) { |
| 998 |
$out['php_timezone'] = (string)date_default_timezone_get(); |
| 999 |
} |
| 1000 |
try { |
| 1001 |
$tz = new \DateTimeZone($out['php_timezone'] !== '' ? $out['php_timezone'] : 'UTC'); |
| 1002 |
$dt = new \DateTime('now', $tz); |
| 1003 |
$out['server_utc_offset_seconds'] = (int)$tz->getOffset($dt); |
| 1004 |
} catch (\Throwable $e) { |
| 1005 |
// allow-silent-catch: server_utc_offset is best-effort; an invalid tz string leaves the default zero in place |
| 1006 |
@error_log('404 Solution: probeTimezone offset probe failed: ' . $e->getMessage()); |
| 1007 |
} |
| 1008 |
return $out; |
| 1009 |
} |
| 1010 |
|
| 1011 |
/** |
| 1012 |
* Install + upgrade timeline. The single most useful bifurcator for |
| 1013 |
* "started after upgrade Tuesday" vs "always broken since install." |
| 1014 |
* Read-only from plugin options the upgrade path already writes; |
| 1015 |
* no new SQL, no new options. |
| 1016 |
* |
| 1017 |
* Fields: |
| 1018 |
* installed_at: int|null unix seconds, from abj404_installed_time |
| 1019 |
* current_version: string ABJ404_VERSION (live) |
| 1020 |
* db_version_option string|null abj404_settings['DB_VERSION'] (the value |
| 1021 |
* stamped at the last upgrade; equals |
| 1022 |
* current_version after the upgrade path |
| 1023 |
* ran, mismatches between upgrade tick |
| 1024 |
* and DB_VERSION write on lock contention) |
| 1025 |
* |
| 1026 |
* @return array<string, mixed> |
| 1027 |
*/ |
| 1028 |
private function probePluginLifecycle(): array { |
| 1029 |
$out = array( |
| 1030 |
'installed_at' => null, |
| 1031 |
'current_version' => defined('ABJ404_VERSION') ? (string)ABJ404_VERSION : '', |
| 1032 |
'db_version_option' => null, |
| 1033 |
); |
| 1034 |
if (function_exists('get_option')) { |
| 1035 |
$t = get_option('abj404_installed_time', null); |
| 1036 |
if (is_scalar($t) && is_numeric($t)) { |
| 1037 |
$out['installed_at'] = (int)$t; |
| 1038 |
} |
| 1039 |
$settings = get_option('abj404_settings', null); |
| 1040 |
if (is_array($settings) && isset($settings['DB_VERSION']) && is_scalar($settings['DB_VERSION'])) { |
| 1041 |
$out['db_version_option'] = (string)$settings['DB_VERSION']; |
| 1042 |
} |
| 1043 |
} |
| 1044 |
return $out; |
| 1045 |
} |
| 1046 |
|
| 1047 |
/** |
| 1048 |
* Top distinct recurring error signatures from the plugin's debug |
| 1049 |
* log file over the last 7 days, capped at 5 entries. The triggering |
| 1050 |
* error is captured by the report itself ('error_signature' on the |
| 1051 |
* payload); this probe captures the recurring error which is often |
| 1052 |
* different and would never reach the email-on-first-error path. |
| 1053 |
* |
| 1054 |
* Bounded cost: reads the tail 256 KB of the debug file, parses |
| 1055 |
* lines matching the canonical "YYYY-MM-DD HH:MM:SS (LEVEL): ..." |
| 1056 |
* shape, keeps only [ERROR]/[WARN] entries within the last 7 days, |
| 1057 |
* groups by a coarse signature (first 200 chars after the level), |
| 1058 |
* keeps the top 5 by count. Returns an empty array on any read |
| 1059 |
* failure. |
| 1060 |
* |
| 1061 |
* Shape: |
| 1062 |
* [ {signature: string, count: int, last_seen_at: int}, ... ] |
| 1063 |
* |
| 1064 |
* @return array<int, array<string, mixed>> |
| 1065 |
*/ |
| 1066 |
private function probeRecentErrorSignatures(): array { |
| 1067 |
$out = array(); |
| 1068 |
try { |
| 1069 |
$log = abj_service('logging'); |
| 1070 |
// allow-silent-catch: container miss is fatal for this probe; null check below records empty |
| 1071 |
} catch (\Throwable $e) { |
| 1072 |
return $out; |
| 1073 |
} |
| 1074 |
if (!is_object($log) || !method_exists($log, 'getDebugFilePath')) { |
| 1075 |
return $out; |
| 1076 |
} |
| 1077 |
$path = (string)$log->getDebugFilePath(); |
| 1078 |
if ($path === '' || !is_file($path) || !is_readable($path)) { |
| 1079 |
return $out; |
| 1080 |
} |
| 1081 |
$size = @filesize($path); |
| 1082 |
if ($size === false || $size === 0) { |
| 1083 |
return $out; |
| 1084 |
} |
| 1085 |
$readBytes = 262144; // 256 KB |
| 1086 |
$offset = $size > $readBytes ? $size - $readBytes : 0; |
| 1087 |
$fh = @fopen($path, 'rb'); |
| 1088 |
if (!is_resource($fh)) { |
| 1089 |
return $out; |
| 1090 |
} |
| 1091 |
$tail = ''; |
| 1092 |
try { |
| 1093 |
if ($offset > 0) { |
| 1094 |
@fseek($fh, $offset); |
| 1095 |
// Discard the partial first line so we only group on whole records. |
| 1096 |
@fgets($fh); |
| 1097 |
} |
| 1098 |
$chunk = @fread($fh, $readBytes); |
| 1099 |
if (is_string($chunk)) { |
| 1100 |
$tail = $chunk; |
| 1101 |
} |
| 1102 |
} finally { |
| 1103 |
@fclose($fh); |
| 1104 |
} |
| 1105 |
if ($tail === '') { |
| 1106 |
return $out; |
| 1107 |
} |
| 1108 |
$cutoff = time() - 7 * 86400; |
| 1109 |
$byKey = array(); |
| 1110 |
$lines = preg_split('/\r?\n/', $tail); |
| 1111 |
if (!is_array($lines)) { |
| 1112 |
return $out; |
| 1113 |
} |
| 1114 |
foreach ($lines as $line) { |
| 1115 |
if (!is_string($line) || $line === '') { continue; } |
| 1116 |
// Match "YYYY-MM-DD HH:MM:SS (LEVEL): tail..." per Logging.php format. |
| 1117 |
if (!preg_match('/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \((ERROR|WARN)\):\s*(.*)$/', $line, $m)) { |
| 1118 |
continue; |
| 1119 |
} |
| 1120 |
$ts = strtotime($m[1]); |
| 1121 |
if ($ts === false || $ts < $cutoff) { continue; } |
| 1122 |
$level = $m[2]; |
| 1123 |
$msg = trim($m[3]); |
| 1124 |
if ($msg === '') { continue; } |
| 1125 |
$sig = $level . ':' . substr($this->normalizeErrorSignature($msg), 0, 200); |
| 1126 |
if (!isset($byKey[$sig])) { |
| 1127 |
$byKey[$sig] = array('signature' => $sig, 'count' => 0, 'last_seen_at' => 0); |
| 1128 |
} |
| 1129 |
$byKey[$sig]['count']++; |
| 1130 |
if ($ts > $byKey[$sig]['last_seen_at']) { |
| 1131 |
$byKey[$sig]['last_seen_at'] = $ts; |
| 1132 |
} |
| 1133 |
} |
| 1134 |
if (empty($byKey)) { |
| 1135 |
return $out; |
| 1136 |
} |
| 1137 |
$list = array_values($byKey); |
| 1138 |
usort($list, function ($a, $b) { |
| 1139 |
$cmp = $b['count'] - $a['count']; |
| 1140 |
if ($cmp !== 0) { return $cmp; } |
| 1141 |
return $b['last_seen_at'] - $a['last_seen_at']; |
| 1142 |
}); |
| 1143 |
return array_slice($list, 0, 5); |
| 1144 |
} |
| 1145 |
|
| 1146 |
/** |
| 1147 |
* Coarse-grain an error message so different incident timestamps, |
| 1148 |
* memory addresses, file paths, and line numbers fold into the same |
| 1149 |
* signature. Used by probeRecentErrorSignatures to group recurring |
| 1150 |
* errors. |
| 1151 |
* |
| 1152 |
* @param string $msg |
| 1153 |
* @return string |
| 1154 |
*/ |
| 1155 |
private function normalizeErrorSignature(string $msg): string { |
| 1156 |
$s = $msg; |
| 1157 |
// Strip absolute paths to just the basename. |
| 1158 |
$s = preg_replace('#/[A-Za-z0-9_\-\./]+/([A-Za-z0-9_\-]+\.php)#', '$1', $s) ?? $s; |
| 1159 |
// Collapse memory addresses, hex, and digit sequences. |
| 1160 |
$s = preg_replace('/\b0x[0-9a-fA-F]+\b/', '0xN', $s) ?? $s; |
| 1161 |
$s = preg_replace('/\b\d{4,}\b/', 'N', $s) ?? $s; |
| 1162 |
// Collapse runs of whitespace. |
| 1163 |
$s = preg_replace('/\s+/', ' ', $s) ?? $s; |
| 1164 |
return trim($s); |
| 1165 |
} |
| 1166 |
|
| 1167 |
/** |
| 1168 |
* opcache detail fields beyond the on/off enum. Each value is |
| 1169 |
* explicitly nullable: ini_get() returns false when the directive |
| 1170 |
* is unknown, and "we couldn't read it" is materially different |
| 1171 |
* from a stamped 0/false the host configured deliberately. |
| 1172 |
* |
| 1173 |
* Shape: |
| 1174 |
* { revalidate_freq: int|null, |
| 1175 |
* validate_timestamps: bool|null, |
| 1176 |
* enable_cli: bool|null } |
| 1177 |
* |
| 1178 |
* @return array<string, mixed> |
| 1179 |
*/ |
| 1180 |
private function probeOpcacheSettings(): array { |
| 1181 |
$out = array( |
| 1182 |
'revalidate_freq' => null, |
| 1183 |
'validate_timestamps' => null, |
| 1184 |
'enable_cli' => null, |
| 1185 |
); |
| 1186 |
if (!function_exists('ini_get')) { |
| 1187 |
return $out; |
| 1188 |
} |
| 1189 |
$rf = ini_get('opcache.revalidate_freq'); |
| 1190 |
if ($rf !== false) { |
| 1191 |
$out['revalidate_freq'] = (int)$rf; |
| 1192 |
} |
| 1193 |
$vt = ini_get('opcache.validate_timestamps'); |
| 1194 |
if ($vt !== false) { |
| 1195 |
$out['validate_timestamps'] = ((int)$vt === 1 || strtolower((string)$vt) === 'on'); |
| 1196 |
} |
| 1197 |
$ec = ini_get('opcache.enable_cli'); |
| 1198 |
if ($ec !== false) { |
| 1199 |
$out['enable_cli'] = ((int)$ec === 1 || strtolower((string)$ec) === 'on'); |
| 1200 |
} |
| 1201 |
return $out; |
| 1202 |
} |
| 1203 |
|
| 1204 |
/** |
| 1205 |
* open_basedir restriction string, or null when not configured. |
| 1206 |
* Returned wholesale (path list) so the server side can match it |
| 1207 |
* against the plugin's known write targets; the value is not PII |
| 1208 |
* and the per-host shapes vary enough that any normalization here |
| 1209 |
* would lose signal. |
| 1210 |
* |
| 1211 |
* @return string|null |
| 1212 |
*/ |
| 1213 |
private function probeOpenBasedir(): ?string { |
| 1214 |
if (!function_exists('ini_get')) { |
| 1215 |
return null; |
| 1216 |
} |
| 1217 |
$v = ini_get('open_basedir'); |
| 1218 |
if (!is_string($v) || $v === '') { |
| 1219 |
return null; |
| 1220 |
} |
| 1221 |
return $v; |
| 1222 |
} |
| 1223 |
|
| 1224 |
/** |
| 1225 |
* Multisite identity for the request the report originates from. |
| 1226 |
* When `is_multisite()` is false the rest of the shape is omitted |
| 1227 |
* rather than emitted as nulls per probe (a single-site install |
| 1228 |
* has no blog_id/network_id and the keys would be misleading). |
| 1229 |
* |
| 1230 |
* Shape (multisite): |
| 1231 |
* { is_multisite: true, |
| 1232 |
* is_main_site: bool|null, |
| 1233 |
* blog_id: int|null, |
| 1234 |
* network_id: int|null, |
| 1235 |
* network_activated: bool|null } |
| 1236 |
* |
| 1237 |
* Shape (single-site): |
| 1238 |
* { is_multisite: false } |
| 1239 |
* |
| 1240 |
* @return array<string, mixed> |
| 1241 |
*/ |
| 1242 |
private function probeMultisiteRole(): array { |
| 1243 |
$isMultisite = function_exists('is_multisite') && (bool)is_multisite(); |
| 1244 |
$out = array('is_multisite' => $isMultisite); |
| 1245 |
if (!$isMultisite) { |
| 1246 |
return $out; |
| 1247 |
} |
| 1248 |
$out['is_main_site'] = function_exists('is_main_site') ? (bool)is_main_site() : null; |
| 1249 |
$out['blog_id'] = function_exists('get_current_blog_id') ? (int)get_current_blog_id() : null; |
| 1250 |
$out['network_id'] = function_exists('get_current_network_id') ? (int)get_current_network_id() : null; |
| 1251 |
|
| 1252 |
$networkActivated = null; |
| 1253 |
if (function_exists('is_plugin_active_for_network') && function_exists('plugin_basename') && defined('ABJ404_FILE')) { |
| 1254 |
try { |
| 1255 |
$networkActivated = (bool) is_plugin_active_for_network(plugin_basename(ABJ404_FILE)); |
| 1256 |
} catch (\Throwable $e) { |
| 1257 |
// allow-silent-catch: best-effort multisite probe; is_plugin_active_for_network requires wp-admin context that may not be loaded on front-end / cron paths, leave null |
| 1258 |
@error_log('404 Solution: probeMultisiteRole network-activated check failed: ' . $e->getMessage()); |
| 1259 |
$networkActivated = null; |
| 1260 |
} |
| 1261 |
} |
| 1262 |
$out['network_activated'] = $networkActivated; |
| 1263 |
return $out; |
| 1264 |
} |
| 1265 |
|
| 1266 |
/** |
| 1267 |
* Whether the .htaccess at the WP home path is writable by the |
| 1268 |
* plugin. Differentiates "Apache rule install will succeed" from |
| 1269 |
* "must use the DB-only redirect handler". Falls back to ABSPATH |
| 1270 |
* when get_home_path() is unavailable (front-end / cron context |
| 1271 |
* loads it on demand from wp-admin/includes/file.php). |
| 1272 |
* |
| 1273 |
* @return bool |
| 1274 |
*/ |
| 1275 |
private function probeHtaccessWritable(): bool { |
| 1276 |
$path = $this->resolveHtaccessPath(); |
| 1277 |
if ($path === '') { |
| 1278 |
return false; |
| 1279 |
} |
| 1280 |
// is_writable() returns false on a non-existent file too, |
| 1281 |
// which matches the install-method intent: if the file does |
| 1282 |
// not yet exist and we cannot write the directory either, the |
| 1283 |
// Apache-rule path cannot succeed. |
| 1284 |
return @is_writable($path); |
| 1285 |
} |
| 1286 |
|
| 1287 |
/** |
| 1288 |
* Best path to test for .htaccess writability. Prefers |
| 1289 |
* get_home_path() (which honors WordPress in-subdir installs); |
| 1290 |
* falls back to ABSPATH for early-boot / front-end contexts where |
| 1291 |
* wp-admin/includes/file.php has not been loaded. |
| 1292 |
* |
| 1293 |
* @return string |
| 1294 |
*/ |
| 1295 |
private function resolveHtaccessPath(): string { |
| 1296 |
if (function_exists('get_home_path')) { |
| 1297 |
$home = (string) get_home_path(); |
| 1298 |
if ($home !== '') { |
| 1299 |
return rtrim($home, "/\\") . '/.htaccess'; |
| 1300 |
} |
| 1301 |
} |
| 1302 |
if (defined('ABSPATH') && ABSPATH !== '') { |
| 1303 |
return rtrim(ABSPATH, "/\\") . '/.htaccess'; |
| 1304 |
} |
| 1305 |
return ''; |
| 1306 |
} |
| 1307 |
|
| 1308 |
/** |
| 1309 |
* Free bytes on the system temp directory's filesystem. Some |
| 1310 |
* shared hosts mount /tmp as a separate quota from the WP install |
| 1311 |
* path; the disk_free_bytes probe (which targets the uploads dir) |
| 1312 |
* cannot see /tmp exhaustion. Throws when disk_free_space is |
| 1313 |
* disabled so the caller's tryInt wrapper records null rather |
| 1314 |
* than a misleading zero. |
| 1315 |
* |
| 1316 |
* @return int |
| 1317 |
*/ |
| 1318 |
private function probeTmpFreeBytesOrThrow(): int { |
| 1319 |
if (!function_exists('disk_free_space')) { |
| 1320 |
throw new \RuntimeException('disk_free_space unavailable'); |
| 1321 |
} |
| 1322 |
$tmp = function_exists('sys_get_temp_dir') ? sys_get_temp_dir() : ''; |
| 1323 |
if ($tmp === '') { |
| 1324 |
throw new \RuntimeException('sys_get_temp_dir returned empty'); |
| 1325 |
} |
| 1326 |
$v = @disk_free_space($tmp); |
| 1327 |
if ($v === false) { |
| 1328 |
throw new \RuntimeException('disk_free_space returned false for ' . $tmp); |
| 1329 |
} |
| 1330 |
return (int)$v; |
| 1331 |
} |
| 1332 |
} |
| 1333 |
|