PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / DataAccessTrait_ViewBuildPhpEnvProbe.php

DataAccessTrait_ViewBuildPhpEnvProbe.php in 404 Solution 4.1.19, at includes/DataAccessTrait_ViewBuildPhpEnvProbe.php

495 lines 19.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * PHP-runtime environment probe for the staged view-build pipeline.
9 *
10 * Detects two host-side constraints that silently destabilize the build on
11 * hardened shared hosts (php.ini disable_functions, low memory_limit):
12 *
13 * 1. `set_time_limit()` in `disable_functions`. The build cannot extend
14 * its time budget mid-stage when the host has revoked it, so the
15 * orchestrator's per-stage budget switches to a tighter cron-tick
16 * mode (yield earlier, rely on the next tick) instead of gambling
17 * on max_execution_time.
18 *
19 * 2. `memory_limit` below the 128M recommended floor. The S9 hits
20 * aggregate (CREATE TEMPORARY + INSERT ... GROUP BY across logsv2)
21 * can OOM on busy sites with a small PHP-side fetch buffer. Since
22 * `ini_set('memory_limit', ...)` is often blocked, the probe
23 * surfaces a deduplicated admin notice instead of silently failing.
24 *
25 * Sibling to ABJ_404_Solution_DataAccess_ViewBuildHelpersTrait. Extracted
26 * from that trait when it crossed the 1500-line limit; the public probe
27 * method is the entry point called from runStagedBuildOnce().
28 */
29 trait ABJ_404_Solution_DataAccess_ViewBuildPhpEnvProbeTrait {
30
31 /**
32 * Cached PHP-environment probe result for the current request:
33 * function_exists('set_time_limit') AND not in disable_functions, plus
34 * memory_limit parsed to bytes. Populated on first call to
35 * probePhpEnvironmentForBuild() and consumed by
36 * viewBuildPerStageBudgetSeconds() to switch into a tighter cron-tick
37 * budget when set_time_limit cannot extend the request mid-stage.
38 *
39 * @var array<string,mixed>|null
40 */
41 private $phpEnvironmentProbeCache = null;
42
43 /**
44 * Cached filesystem probe result for the current request.
45 *
46 * @var array<string,mixed>|null
47 */
48 private $filesystemEnvironmentProbeCache = null;
49
50 /** @return string Option name for the persisted PHP environment probe. */
51 private function phpEnvironmentProbeOptionName(): string {
52 return 'abj404_view_build_php_env_probe';
53 }
54
55 /**
56 * Probe the PHP runtime for environmental constraints that affect the
57 * staged view build:
58 *
59 * - `set_time_limit()` in `disable_functions`: the build cannot extend
60 * its time budget mid-stage on hardened shared hosts. The orchestrator
61 * consumes this flag in viewBuildPerStageBudgetSeconds() to yield
62 * earlier and rely on the next cron tick.
63 *
64 * - `memory_limit` below the 128M recommended floor: the S9 hits
65 * aggregate (CREATE TEMPORARY + INSERT ... GROUP BY across logsv2)
66 * can OOM on busy sites. We cannot bump memory_limit at runtime on
67 * hardened hosts, so surface a deduplicated admin notice instead.
68 *
69 * Side effects: persists the probe result to an option for post-mortem
70 * dashboards, and surfaces a low-memory admin notice (one per 24h via
71 * transient dedup) when the floor check fails. Idempotent within a
72 * request -- repeat calls return the cached array without re-probing.
73 *
74 * Filterable via `apply_filters('abj404_php_env_probe', $defaults)` so
75 * tests and operators can simulate disable_functions / low memory_limit
76 * without mutating the running PHP process. Filter callers may add or
77 * widen keys, so the return type is the loose `array<string,mixed>`.
78 * Internally guaranteed keys: set_time_limit_available (bool),
79 * memory_limit_raw (string), memory_limit_bytes (int), memory_limit_low
80 * (bool).
81 *
82 * @return array<string,mixed>
83 */
84 public function probePhpEnvironmentForBuild(): array {
85 if (is_array($this->phpEnvironmentProbeCache)) {
86 return $this->phpEnvironmentProbeCache;
87 }
88
89 $rawMemory = (string)ini_get('memory_limit');
90 $memoryBytes = $this->parsePhpMemoryLimitToBytes($rawMemory);
91
92 $disabled = $this->phpDisabledFunctionsList();
93 $setTimeLimitAvailable = function_exists('set_time_limit')
94 && !in_array('set_time_limit', $disabled, true);
95
96 $result = array(
97 'set_time_limit_available' => $setTimeLimitAvailable,
98 'memory_limit_raw' => $rawMemory,
99 'memory_limit_bytes' => $memoryBytes,
100 // memory_limit_bytes == 0 means unlimited (-1 in php.ini), which
101 // is fine and is NOT "low".
102 'memory_limit_low' => ($memoryBytes > 0
103 && $memoryBytes < ABJ_404_Solution_ViewBuildConfig::PHP_MEMORY_LIMIT_RECOMMENDED_BYTES),
104 );
105
106 if (function_exists('apply_filters')) {
107 $filtered = apply_filters('abj404_php_env_probe', $result);
108 if (is_array($filtered)) {
109 $result = array_merge($result, $filtered);
110 }
111 }
112
113 if (empty($result['set_time_limit_available'])) {
114 $this->logger->infoMessage(
115 '[staged] set_time_limit() unavailable (disable_functions); '
116 . 'switching to tighter cron-tick budget mode.'
117 );
118 }
119 if (!empty($result['memory_limit_low'])) {
120 $resultMemoryBytes = isset($result['memory_limit_bytes']) && is_numeric($result['memory_limit_bytes'])
121 ? (int)$result['memory_limit_bytes'] : 0;
122 $this->setLowMemoryLimitAdminNotice($resultMemoryBytes);
123 }
124
125 if (function_exists('update_option')) {
126 update_option($this->phpEnvironmentProbeOptionName(), $result, false);
127 }
128
129 $this->phpEnvironmentProbeCache = $result;
130 return $result;
131 }
132
133 /**
134 * Contract alias: returns just the boolean used by the env-failure tests.
135 * Keeps the public surface compact for callers that only need the flag.
136 *
137 * @return bool
138 */
139 public function probeSetTimeLimitAvailability(): bool {
140 $probe = $this->probePhpEnvironmentForBuild();
141 return !empty($probe['set_time_limit_available']);
142 }
143
144 /**
145 * Contract alias: returns memory_limit in bytes (0 == unlimited) so
146 * callers can choose chunking vs. skip without re-parsing the ini value.
147 *
148 * @return int
149 */
150 public function probeMemoryLimitForS9(): int {
151 $probe = $this->probePhpEnvironmentForBuild();
152 $bytes = $probe['memory_limit_bytes'] ?? 0;
153 return is_numeric($bytes) ? (int)$bytes : 0;
154 }
155
156 /**
157 * Parse a php.ini-style memory size (`128M`, `1G`, `262144`, `-1`) into
158 * raw bytes. Returns 0 for "unlimited" (-1) or unparseable input.
159 *
160 * @param string $raw
161 * @return int
162 */
163 private function parsePhpMemoryLimitToBytes(string $raw): int {
164 $raw = trim($raw);
165 if ($raw === '' || $raw === '-1' || $raw === '0') {
166 return 0;
167 }
168 $unit = strtoupper(substr($raw, -1));
169 $num = (int)$raw;
170 if ($num <= 0) {
171 return 0;
172 }
173 switch ($unit) {
174 case 'G': return $num * 1073741824;
175 case 'M': return $num * 1048576;
176 case 'K': return $num * 1024;
177 default:
178 return is_numeric($raw) ? (int)$raw : 0;
179 }
180 }
181
182 /**
183 * @return array<int,string> Trimmed list of names from ini disable_functions.
184 */
185 private function phpDisabledFunctionsList(): array {
186 $raw = (string)ini_get('disable_functions');
187 if ($raw === '') {
188 return array();
189 }
190 $names = array_map('trim', explode(',', $raw));
191 return array_values(array_filter($names, function ($n) { return $n !== ''; }));
192 }
193
194 /**
195 * Surface a deduplicated admin notice when the host's memory_limit is
196 * below the recommended 128M floor. One per 24h per failure type, per
197 * the self-healing reliability rules in CLAUDE.md (notices on the
198 * plugin's own admin screen, never email, never wp-admin-wide banner).
199 *
200 * @param int $memoryBytes
201 * @return void
202 */
203 private function setLowMemoryLimitAdminNotice(int $memoryBytes): void {
204 $key = 'abj404_view_build_low_memory_limit_notice';
205 $payload = array(
206 'kind' => 'low_memory_limit',
207 'bytes' => $memoryBytes,
208 'recommended' => ABJ_404_Solution_ViewBuildConfig::PHP_MEMORY_LIMIT_RECOMMENDED_BYTES,
209 'message' => sprintf(
210 'Your PHP memory_limit (%s) is below the recommended 128M; '
211 . 'the redirect view rebuild may fail on large sites.',
212 $this->formatPhpMemoryBytesHuman($memoryBytes)
213 ),
214 'when' => $this->clock()->now(),
215 );
216 if (function_exists('set_transient')) {
217 set_transient(
218 $key,
219 $payload,
220 ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
221 );
222 } elseif (function_exists('update_option')) {
223 update_option($key, $payload, false);
224 }
225 }
226
227 /**
228 * Format a byte count as a php.ini-style suffix string for admin notices.
229 *
230 * @param int $bytes
231 * @return string
232 */
233 private function formatPhpMemoryBytesHuman(int $bytes): string {
234 if ($bytes <= 0) {
235 return 'unlimited';
236 }
237 if ($bytes >= 1073741824) {
238 $g = $bytes / 1073741824;
239 return ($g == (int)$g ? (string)(int)$g : number_format($g, 1)) . 'G';
240 }
241 if ($bytes >= 1048576) {
242 return (string)(int)round($bytes / 1048576) . 'M';
243 }
244 if ($bytes >= 1024) {
245 return (string)(int)round($bytes / 1024) . 'K';
246 }
247 return (string)$bytes;
248 }
249
250 /** @return void */
251 private function clearPhpEnvironmentProbeCache(): void {
252 $this->phpEnvironmentProbeCache = null;
253 $this->filesystemEnvironmentProbeCache = null;
254 if (function_exists('delete_option')) {
255 delete_option($this->phpEnvironmentProbeOptionName());
256 delete_option($this->filesystemEnvironmentProbeOptionName());
257 }
258 }
259
260 /** @return string */
261 private function filesystemEnvironmentProbeOptionName(): string {
262 return 'abj404_view_build_fs_env_probe';
263 }
264
265 /**
266 * Probe filesystem-side host constraints that can silently degrade or
267 * abort the staged view-build pipeline:
268 *
269 * - `open_basedir` set and our tmp/upload paths fall outside it: any
270 * `disk_free_space()` / fopen() against those paths returns false
271 * and the build cannot diagnose why.
272 * - `upload_tmp_dir` outside open_basedir: same constraint.
273 * - `@@tmpdir` (MySQL temp dir) on a near-full volume: S9 hits aggregate
274 * can fail with "table is full" or "No space left on device" when the
275 * temp file the optimizer materializes for the GROUP BY exceeds free
276 * bytes.
277 *
278 * Read-and-warn-only: never throws, never blocks the build. Logs at
279 * warning level (per defensive philosophy §8 -- infrastructure issues the
280 * plugin can degrade past) and surfaces a deduplicated admin notice so
281 * the operator can ask the host to widen open_basedir or clear disk space
282 * before the next build attempt.
283 *
284 * Filterable via `apply_filters('abj404_filesystem_env_probe', $defaults)`
285 * so tests and operators can simulate hardened-host scenarios without
286 * mutating the running PHP / MySQL process.
287 *
288 * @return array<string,mixed>
289 */
290 public function probeFilesystemEnvironmentForBuild(): array {
291 if (is_array($this->filesystemEnvironmentProbeCache)) {
292 return $this->filesystemEnvironmentProbeCache;
293 }
294
295 $rawOpenBasedir = (string)ini_get('open_basedir');
296 $rawUploadTmpDir = (string)ini_get('upload_tmp_dir');
297 $sysTmpDir = function_exists('sys_get_temp_dir') ? (string)sys_get_temp_dir() : '';
298
299 $pluginTmpCandidates = array_filter(array(
300 $sysTmpDir,
301 $rawUploadTmpDir,
302 ), function ($p) { return $p !== ''; });
303
304 $openBasedirPaths = $this->splitOpenBasedirPaths($rawOpenBasedir);
305
306 $tmpOutsideOpenBasedir = false;
307 $uploadTmpOutsideOpenBasedir = false;
308 if (!empty($openBasedirPaths)) {
309 foreach ($pluginTmpCandidates as $candidate) {
310 if (!$this->pathFallsWithinAny($candidate, $openBasedirPaths)) {
311 $tmpOutsideOpenBasedir = true;
312 break;
313 }
314 }
315 if ($rawUploadTmpDir !== ''
316 && !$this->pathFallsWithinAny($rawUploadTmpDir, $openBasedirPaths)) {
317 $uploadTmpOutsideOpenBasedir = true;
318 }
319 }
320
321 $tmpDirForCheck = $rawUploadTmpDir !== '' ? $rawUploadTmpDir : $sysTmpDir;
322 $tmpFreeBytes = -1;
323 if ($tmpDirForCheck !== ''
324 && function_exists('disk_free_space')
325 && (empty($openBasedirPaths) || $this->pathFallsWithinAny($tmpDirForCheck, $openBasedirPaths))) {
326 $prev = function_exists('error_reporting') ? error_reporting(0) : 0;
327 try {
328 $bytes = @disk_free_space($tmpDirForCheck);
329 $tmpFreeBytes = ($bytes === false) ? -1 : (int)$bytes;
330 } catch (\Throwable $e) { // allow-silent-catch: best-effort probe; reset error_reporting in finally
331 $tmpFreeBytes = -1;
332 }
333 if (function_exists('error_reporting')) {
334 error_reporting($prev);
335 }
336 }
337 $tmpDiskLow = ($tmpFreeBytes >= 0 && $tmpFreeBytes < ABJ_404_Solution_ViewBuildConfig::PHP_TMPDIR_FREE_FLOOR_BYTES);
338
339 $result = array(
340 'open_basedir_raw' => $rawOpenBasedir,
341 'open_basedir_paths' => $openBasedirPaths,
342 'upload_tmp_dir_raw' => $rawUploadTmpDir,
343 'sys_tmp_dir' => $sysTmpDir,
344 'tmp_outside_open_basedir' => $tmpOutsideOpenBasedir,
345 'upload_tmp_outside_open_basedir' => $uploadTmpOutsideOpenBasedir,
346 'tmp_free_bytes' => $tmpFreeBytes,
347 'tmp_disk_low' => $tmpDiskLow,
348 'tmp_disk_floor_bytes' => ABJ_404_Solution_ViewBuildConfig::PHP_TMPDIR_FREE_FLOOR_BYTES,
349 );
350
351 if (function_exists('apply_filters')) {
352 $filtered = apply_filters('abj404_filesystem_env_probe', $result);
353 if (is_array($filtered)) {
354 $result = array_merge($result, $filtered);
355 }
356 }
357
358 $warnings = array();
359 $resultOpenBasedirRaw = isset($result['open_basedir_raw']) && is_scalar($result['open_basedir_raw'])
360 ? (string)$result['open_basedir_raw'] : '';
361 $resultSysTmpDir = isset($result['sys_tmp_dir']) && is_scalar($result['sys_tmp_dir'])
362 ? (string)$result['sys_tmp_dir'] : '';
363 $resultUploadTmpDirRaw = isset($result['upload_tmp_dir_raw']) && is_scalar($result['upload_tmp_dir_raw'])
364 ? (string)$result['upload_tmp_dir_raw'] : '';
365 $resultTmpFreeBytes = isset($result['tmp_free_bytes']) && is_numeric($result['tmp_free_bytes'])
366 ? (int)$result['tmp_free_bytes'] : -1;
367 if (!empty($result['tmp_outside_open_basedir'])) {
368 $warnings[] = sprintf(
369 'open_basedir (%s) does not include the system temp directory (%s); '
370 . 'PHP-side temp file work may fail.',
371 $resultOpenBasedirRaw, $resultSysTmpDir
372 );
373 }
374 if (!empty($result['upload_tmp_outside_open_basedir'])) {
375 $warnings[] = sprintf(
376 'upload_tmp_dir (%s) is outside open_basedir (%s); ini upload paths cannot be probed.',
377 $resultUploadTmpDirRaw, $resultOpenBasedirRaw
378 );
379 }
380 if (!empty($result['tmp_disk_low'])) {
381 $warnings[] = sprintf(
382 'temp directory (%s) has %d bytes free (< %d MB floor); the S9 hits '
383 . 'aggregate or any MySQL temp materialization may fail with "No space left on device".',
384 $tmpDirForCheck,
385 $resultTmpFreeBytes,
386 (int)(ABJ_404_Solution_ViewBuildConfig::PHP_TMPDIR_FREE_FLOOR_BYTES / 1048576)
387 );
388 }
389 $result['warnings'] = $warnings;
390
391 foreach ($warnings as $w) {
392 $this->logger->warn('[staged] ' . $w);
393 }
394 if (!empty($warnings)) {
395 $this->setFilesystemEnvAdminNotice($result);
396 }
397
398 if (function_exists('update_option')) {
399 update_option($this->filesystemEnvironmentProbeOptionName(), $result, false);
400 }
401
402 $this->filesystemEnvironmentProbeCache = $result;
403 return $result;
404 }
405
406 /**
407 * Split a raw open_basedir value (`PATH_SEPARATOR`-delimited) into a
408 * trimmed list of absolute path prefixes. Empty input returns array().
409 *
410 * @param string $raw
411 * @return array<int,string>
412 */
413 private function splitOpenBasedirPaths(string $raw): array {
414 $raw = trim($raw);
415 if ($raw === '') { return array(); }
416 $sep = defined('PATH_SEPARATOR') ? PATH_SEPARATOR : ':';
417 $parts = array_map('trim', explode($sep, $raw));
418 return array_values(array_filter($parts, function ($p) { return $p !== ''; }));
419 }
420
421 /**
422 * True when $candidate falls within at least one of $allowed (string
423 * prefix match after normalizing trailing separators). Normalizes both
424 * sides via realpath() when available so symlinks resolve consistently.
425 *
426 * @param string $candidate
427 * @param array<int,string> $allowed
428 * @return bool
429 */
430 private function pathFallsWithinAny(string $candidate, array $allowed): bool {
431 if ($candidate === '' || empty($allowed)) { return true; }
432 $normCandidate = $this->normalizePathPrefix($candidate);
433 foreach ($allowed as $a) {
434 $normA = $this->normalizePathPrefix($a);
435 if ($normA === '') { continue; }
436 if (strncmp($normCandidate, $normA, strlen($normA)) === 0) {
437 return true;
438 }
439 }
440 return false;
441 }
442
443 /**
444 * Normalize a path for prefix comparison: realpath() if it exists, else
445 * trim trailing separators. Returns '' on bad input.
446 *
447 * @param string $path
448 * @return string
449 */
450 private function normalizePathPrefix(string $path): string {
451 $path = trim($path);
452 if ($path === '') { return ''; }
453 if (function_exists('realpath')) {
454 $real = @realpath($path);
455 if (is_string($real)) {
456 return rtrim($real, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
457 }
458 }
459 return rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
460 }
461
462 /**
463 * Surface a deduplicated admin notice describing filesystem-side host
464 * issues. One per 24h via transient (per CLAUDE.md self-healing rules).
465 *
466 * @param array<string,mixed> $probe
467 * @return void
468 */
469 private function setFilesystemEnvAdminNotice(array $probe): void {
470 $key = 'abj404_view_build_filesystem_env_notice';
471 $rawWarnings = isset($probe['warnings']) && is_array($probe['warnings']) ? $probe['warnings'] : array();
472 $stringWarnings = array();
473 foreach ($rawWarnings as $w) {
474 if (is_string($w)) { $stringWarnings[] = $w; }
475 }
476 $payload = array(
477 'kind' => 'filesystem_env',
478 'warnings' => $stringWarnings,
479 'message' => 'The 404 Solution view-build pipeline detected filesystem '
480 . 'host constraints that may degrade the next rebuild: '
481 . implode(' | ', $stringWarnings),
482 'when' => $this->clock()->now(),
483 );
484 if (function_exists('set_transient')) {
485 set_transient(
486 $key,
487 $payload,
488 ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
489 );
490 } elseif (function_exists('update_option')) {
491 update_option($key, $payload, false);
492 }
493 }
494 }
495