PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
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 / ViewBuildPhpEnvProbe.php

ViewBuildPhpEnvProbe.php in 404 Solution 4.2.0, at includes/ViewBuildPhpEnvProbe.php

703 lines 31.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 * @property ABJ_404_Solution_DatabaseCore $dbCore
30 * @property ABJ_404_Solution_Functions $f
31 * @property ABJ_404_Solution_Logging $logger
32 * @property ABJ_404_Solution_ViewReadService|null $viewReadService
33 * @property ABJ_404_Solution_LogsRepository|null $logsRepo
34 * @property int $stagedQueryTimeoutSeconds
35 * @property string $lastBatchProgressDetail
36 * @property bool $viewBuildStageOpenForShutdown
37 * @property int $viewBuildShutdownStageNumber
38 * @property string $viewBuildShutdownStageKey
39 * @property bool|null $namedLockSupportedThisRequest
40 * @property bool $fallbackLockLoggedThisRequest
41 * @property bool $usingTransientFallbackLock
42 * @property string $lastNamedLockUnsupportedReason
43 * @property string $lastNamedLockUnsupportedError
44 * @method void abortStagedBuildForMutationWatermarkAdvance(...$arguments)
45 * @method bool acquireTransientFallbackLock(...$arguments)
46 * @method bool acquireViewBuildLock(...$arguments)
47 * @method string activeBuildStartedWatermarkOptionName(...$arguments)
48 * @method bool adminMutationGateBlocks(...$arguments)
49 * @method array<mixed> advanceViewBuildOnce(...$arguments)
50 * @method void assertBuildBufferExistsOrHalt(...$arguments)
51 * @method ?bool attemptRelaxSqlModeForBuildConnection(...$arguments)
52 * @method bool bufferIntegrityPassesForPromote(...$arguments)
53 * @method string buildHaltTransientKey(...$arguments)
54 * @method string buildViewDoneCountQuery(...$arguments)
55 * @method string builtWatermarkOptionName(...$arguments)
56 * @method int bumpMutationWatermark(...$arguments)
57 * @method int bumpStageNoProgressStreak(...$arguments)
58 * @method string capturedPrefixForLog(...$arguments)
59 * @method void capturePrefixAtBuildStart(...$arguments)
60 * @method void claimForegroundViewBuildLease(...$arguments)
61 * @method string classifyAndHandleStageFailure(...$arguments)
62 * @method array<mixed> classifySessionVariableWarnings(...$arguments)
63 * @method string classifyStageFailure(...$arguments)
64 * @method void clearActiveBuildStartedWatermark(...$arguments)
65 * @method void clearAdminMutationGateOptions(...$arguments)
66 * @method void clearAllProgressOptions(...$arguments)
67 * @method void clearPhpEnvironmentProbeCache(...$arguments)
68 * @method void clearPrefixAtStageOne(...$arguments)
69 * @method void clearSessionVariablesProbeCache(...$arguments)
70 * @method void clearSqlModeProbeCache(...$arguments)
71 * @method void clearStagedBuildDegradedState(...$arguments)
72 * @method void clearViewBuildOpenStageForShutdown(...$arguments)
73 * @method void clearViewDoneHardStaleNotice(...$arguments)
74 * @method ABJ_404_Solution_Clock clock(...$arguments)
75 * @method int countLiveRedirects(...$arguments)
76 * @method int countViewBuildRows(...$arguments)
77 * @method string describeBuildProgressForNotice(...$arguments)
78 * @method string describeDegradedNotice(...$arguments)
79 * @method string describeStagedSqlFailure(...$arguments)
80 * @method array<mixed> detectAndAdjustSqlMode(...$arguments)
81 * @method float detectHostStagedQueryLimitSeconds(...$arguments)
82 * @method string doTableNameReplacements(...$arguments)
83 * @method void dropDeletemeTable(...$arguments)
84 * @method void dropTransientBuffersIfPresent(...$arguments)
85 * @method void dropTransientStagedTables(...$arguments)
86 * @method void ensureConnection(...$arguments)
87 * @method void ensureFallbackLockNoticeAndLog(...$arguments)
88 * @method int extendedTimeoutForKilledNonBatchedStage(...$arguments)
89 * @method array<mixed> fetchSessionVariablesRowOrEmpty(...$arguments)
90 * @method string filesystemEnvironmentProbeOptionName(...$arguments)
91 * @method bool forceRestartViewBuild(...$arguments)
92 * @method bool foregroundViewBuildLeaseActive(...$arguments)
93 * @method string formatPhpMemoryBytesHuman(...$arguments)
94 * @method bool gateAbortIfMutationWatermarkAdvanced(...$arguments)
95 * @method string getColumnCollationString(...$arguments)
96 * @method int getCronStuckHours(...$arguments)
97 * @method string getLowercasePrefix(...$arguments)
98 * @method array<string, mixed> getViewBuildProgress(...$arguments)
99 * @method array<mixed> getViewBuildProgressFingerprint(...$arguments)
100 * @method int getViewDoneBuiltAtTimestamp(...$arguments)
101 * @method bool haltIfPrefixChangedSinceStageOne(...$arguments)
102 * @method string humanBatchProgress(...$arguments)
103 * @method float intelligentStagedQueryTimeoutSeconds(...$arguments)
104 * @method void invalidateViewDoneServeableCache(...$arguments)
105 * @method bool isBuildHaltedForHostFailure(...$arguments)
106 * @method bool isCurrentStageOptionName(...$arguments)
107 * @method bool isNamedLockUnsupportedError(...$arguments)
108 * @method bool isResumableStagedKill(...$arguments)
109 * @method bool isStageMarkedSkipped(...$arguments)
110 * @method bool isTransientConnectionError(...$arguments)
111 * @method string lastBuildStartedWatermarkOptionName(...$arguments)
112 * @method string legacyStartedWatermarkOptionName(...$arguments)
113 * @method string localizeOrDefaultViewBuildNotice(...$arguments)
114 * @method bool logsHitsTableExists(...$arguments)
115 * @method void logTimedViewBuildStage(...$arguments)
116 * @method void logViewBuildProgressOptionWrite(...$arguments)
117 * @method void logViewBuildShutdownDiagnostics(...$arguments)
118 * @method void markBuildHaltedForHostFailure(...$arguments)
119 * @method void markBuildStage(...$arguments)
120 * @method void markStageSkippedForHostFailure(...$arguments)
121 * @method void markViewBuildStageCompleted(...$arguments)
122 * @method void markViewBuildStageStarted(...$arguments)
123 * @method void markViewDoneBuildCompleted(...$arguments)
124 * @method void markViewDoneInvalidatedByAdminMutation(...$arguments)
125 * @method int maxBuildBufferId(...$arguments)
126 * @method void maybeRaiseViewDoneHardStaleNotice(...$arguments)
127 * @method bool mutationWatermarkAdvancedSinceBuildStart(...$arguments)
128 * @method int mutationWatermarkObservedByAdminAction(...$arguments)
129 * @method int mutationWatermarkObservedByAdminActionAt(...$arguments)
130 * @method string mutationWatermarkObservedByAdminActionAtOptionName(...$arguments)
131 * @method string mutationWatermarkObservedByAdminActionOptionName(...$arguments)
132 * @method string normalizePathPrefix(...$arguments)
133 * @method bool optionReadBackMatches(...$arguments)
134 * @method int parsePhpMemoryLimitToBytes(...$arguments)
135 * @method bool pathFallsWithinAny(...$arguments)
136 * @method void performFreshStartCleanup(...$arguments)
137 * @method array<mixed> phpDisabledFunctionsList(...$arguments)
138 * @method string phpEnvironmentProbeOptionName(...$arguments)
139 * @method float phpTimeRemainingSeconds(...$arguments)
140 * @method string prefixAtStageOneOptionName(...$arguments)
141 * @method array<mixed> probeFilesystemEnvironmentForBuild(...$arguments)
142 * @method float probeFloatFromValues(...$arguments)
143 * @method int probeIntFromValues(...$arguments)
144 * @method int probeMemoryLimitForS9(...$arguments)
145 * @method array<mixed> probePhpEnvironmentForBuild(...$arguments)
146 * @method array<mixed> probeSessionVariablesAtS1Entry(...$arguments)
147 * @method bool probeSetTimeLimitAvailability(...$arguments)
148 * @method array<mixed> probeSqlModeForBuild(...$arguments)
149 * @method string probeStringFromValues(...$arguments)
150 * @method string progressOptionName(...$arguments)
151 * @method void publishBuiltWatermarkFromActiveBuildStartedWatermark(...$arguments)
152 * @method array<mixed> queryAndGetResults(...$arguments)
153 * @method int readActiveBuildStartedWatermark(...$arguments)
154 * @method array<int, array<string, mixed>> readFromViewDone(...$arguments)
155 * @method int readProgressOption(...$arguments)
156 * @method int readWatermarkOption(...$arguments)
157 * @method void rebuildViewDoneInBackground(...$arguments)
158 * @method bool reconcilePostStageElevenState(...$arguments)
159 * @method string reconcileStagedTablesAtRunnerStartup(...$arguments)
160 * @method int recordStageBatchKilled(...$arguments)
161 * @method void registerViewBuildShutdownDiagnostics(...$arguments)
162 * @method bool releaseAndReacquireBetweenStages(...$arguments)
163 * @method void releaseViewBuildLock(...$arguments)
164 * @method void resetStageNoProgressStreak(...$arguments)
165 * @method string resolveColumnCollationForStagedBuild(...$arguments)
166 * @method void runForceRestartCleanupInsideLock(...$arguments)
167 * @method bool runIdRangeBatchedUpdate(...$arguments)
168 * @method int runInsertBatch(...$arguments)
169 * @method mixed runNonBatchedStageWithKillStreakEscape(...$arguments)
170 * @method array{ran: bool, reason: string, progress: array<string, mixed>} runPageLoadFallbackAdvance(...$arguments)
171 * @method int runRedirectsForViewCountStaged(...$arguments)
172 * @method array<int, array<string, mixed>> runRedirectsForViewStaged(...$arguments)
173 * @method bool runS11SwapWithPreRenameWatermarkRecheck(...$arguments)
174 * @method bool runStagedBuildOnce(...$arguments)
175 * @method bool runStagedBuildStages6Through11(...$arguments)
176 * @method void runStagedSqlFile(...$arguments)
177 * @method void runStagedSqlFileTolerantOfDuplicateKey(...$arguments)
178 * @method mixed runTimedViewBuildStage(...$arguments)
179 * @method int safeCurrentMutationWatermark(...$arguments)
180 * @method string sanitizeUrlBeforeInsert(...$arguments)
181 * @method void scheduleViewDoneRebuild(...$arguments)
182 * @method string sessionVariablesProbeOptionName(...$arguments)
183 * @method void setFilesystemEnvAdminNotice(...$arguments)
184 * @method void setLowMemoryLimitAdminNotice(...$arguments)
185 * @method void setSessionEnvAdminNotice(...$arguments)
186 * @method void setStagedBuildDegradedNotice(...$arguments)
187 * @method void setStagedBuildHaltNotice(...$arguments)
188 * @method void setViewBuildCronStuckNotice(...$arguments)
189 * @method void setViewBuildScheduleFailedNotice(...$arguments)
190 * @method void setViewDoneHardStaleNotice(...$arguments)
191 * @method array<mixed> splitOpenBasedirPaths(...$arguments)
192 * @method string sqlModeProbeOptionName(...$arguments)
193 * @method void stageAddPreJoinIndexes(...$arguments)
194 * @method void stageAddSortIndexes(...$arguments)
195 * @method void stageCreateBuildTable(...$arguments)
196 * @method array<string, mixed> stagedQueryOptions(...$arguments)
197 * @method bool stagedTableExists(...$arguments)
198 * @method bool stageInsertRedirectsBatched(...$arguments)
199 * @method string stageNoProgressStreakOptionName(...$arguments)
200 * @method void stageRenameSwap(...$arguments)
201 * @method string stageSkipOptionName(...$arguments)
202 * @method void stageUpdateExternal(...$arguments)
203 * @method void stageUpdateHits(...$arguments)
204 * @method void stageUpdateHome(...$arguments)
205 * @method bool stageUpdatePostsBatched(...$arguments)
206 * @method void stageUpdateSpecial(...$arguments)
207 * @method bool stageUpdateTermsBatched(...$arguments)
208 * @method void stampStartedWatermarksAtS1Entry(...$arguments)
209 * @method void sweepStaleRebuildTransients(...$arguments)
210 * @method string transientFallbackLockOptionName(...$arguments)
211 * @method bool verifyBuildLockSerializesWriter(...$arguments)
212 * @method bool verifyOptionWriteCoherent(...$arguments)
213 * @method bool verifyPrefixUnchangedSinceStageOne(...$arguments)
214 * @method int viewBuildBatchSize(...$arguments)
215 * @method int viewBuildBatchSizeForStage(...$arguments)
216 * @method array<mixed> viewBuildOnlyTranslations(...$arguments)
217 * @method float viewBuildPerStageBudgetSeconds(...$arguments)
218 * @method string viewBuildTableName(...$arguments)
219 * @method string viewDeletemeTableName(...$arguments)
220 * @method int viewDoneBuiltAt(...$arguments)
221 * @method int viewDoneBuiltWatermark(...$arguments)
222 * @method int viewDoneDataBuiltAt(...$arguments)
223 * @method string viewDoneDataBuiltAtOptionName(...$arguments)
224 * @method string viewDoneFreshnessOptionName(...$arguments)
225 * @method bool viewDoneHasRows(...$arguments)
226 * @method bool viewDoneIsFresh(...$arguments)
227 * @method bool viewDoneIsServeable(...$arguments)
228 * @method int viewDoneMutationInvalidatedAt(...$arguments)
229 * @method string viewDoneMutationInvalidatedAtOptionName(...$arguments)
230 * @method bool viewDoneTableExists(...$arguments)
231 * @method string viewDoneTableName(...$arguments)
232 * @method void writeProgressOption(...$arguments)
233 * @method void writeWatermarkOption(...$arguments)
234 */
235 class ABJ_404_Solution_ViewBuildPhpEnvProbe extends ABJ_404_Solution_ViewBuildCollaborator {
236
237 /**
238 * Cached PHP-environment probe result for the current request:
239 * function_exists('set_time_limit') AND not in disable_functions, plus
240 * memory_limit parsed to bytes. Populated on first call to
241 * probePhpEnvironmentForBuild() and consumed by
242 * viewBuildPerStageBudgetSeconds() to switch into a tighter cron-tick
243 * budget when set_time_limit cannot extend the request mid-stage.
244 *
245 * @var array<string,mixed>|null
246 */
247 private $phpEnvironmentProbeCache = null;
248
249 /**
250 * Cached filesystem probe result for the current request.
251 *
252 * @var array<string,mixed>|null
253 */
254 private $filesystemEnvironmentProbeCache = null;
255
256 /** @return string Option name for the persisted PHP environment probe. */
257 public function phpEnvironmentProbeOptionName(): string {
258 return 'abj404_view_build_php_env_probe';
259 }
260
261 /**
262 * Probe the PHP runtime for environmental constraints that affect the
263 * staged view build:
264 *
265 * - `set_time_limit()` in `disable_functions`: the build cannot extend
266 * its time budget mid-stage on hardened shared hosts. The orchestrator
267 * consumes this flag in viewBuildPerStageBudgetSeconds() to yield
268 * earlier and rely on the next cron tick.
269 *
270 * - `memory_limit` below the 128M recommended floor: the S9 hits
271 * aggregate (CREATE TEMPORARY + INSERT ... GROUP BY across logsv2)
272 * can OOM on busy sites. We cannot bump memory_limit at runtime on
273 * hardened hosts, so surface a deduplicated admin notice instead.
274 *
275 * Side effects: persists the probe result to an option for post-mortem
276 * dashboards, and surfaces a low-memory admin notice (one per 24h via
277 * transient dedup) when the floor check fails. Idempotent within a
278 * request -- repeat calls return the cached array without re-probing.
279 *
280 * Filterable via `apply_filters('abj404_php_env_probe', $defaults)` so
281 * tests and operators can simulate disable_functions / low memory_limit
282 * without mutating the running PHP process. Filter callers may add or
283 * widen keys, so the return type is the loose `array<string,mixed>`.
284 * Internally guaranteed keys: set_time_limit_available (bool),
285 * memory_limit_raw (string), memory_limit_bytes (int), memory_limit_low
286 * (bool).
287 *
288 * @return array<string,mixed>
289 */
290 public function probePhpEnvironmentForBuild(): array {
291 if (is_array($this->phpEnvironmentProbeCache)) {
292 return $this->phpEnvironmentProbeCache;
293 }
294
295 $rawMemory = (string)ini_get('memory_limit');
296 $memoryBytes = $this->parsePhpMemoryLimitToBytes($rawMemory);
297
298 $disabled = $this->phpDisabledFunctionsList();
299 $setTimeLimitAvailable = function_exists('set_time_limit')
300 && !in_array('set_time_limit', $disabled, true);
301
302 $result = array(
303 'set_time_limit_available' => $setTimeLimitAvailable,
304 'memory_limit_raw' => $rawMemory,
305 'memory_limit_bytes' => $memoryBytes,
306 // memory_limit_bytes == 0 means unlimited (-1 in php.ini), which
307 // is fine and is NOT "low".
308 'memory_limit_low' => ($memoryBytes > 0
309 && $memoryBytes < ABJ_404_Solution_ViewBuildConfig::PHP_MEMORY_LIMIT_RECOMMENDED_BYTES),
310 );
311
312 if (function_exists('apply_filters')) {
313 $filtered = apply_filters('abj404_php_env_probe', $result);
314 if (is_array($filtered)) {
315 $result = array_merge($result, $filtered);
316 }
317 }
318
319 if (empty($result['set_time_limit_available'])) {
320 $this->logger->infoMessage(
321 '[staged] set_time_limit() unavailable (disable_functions); '
322 . 'switching to tighter cron-tick budget mode.'
323 );
324 }
325 if (!empty($result['memory_limit_low'])) {
326 $resultMemoryBytes = isset($result['memory_limit_bytes']) && is_numeric($result['memory_limit_bytes'])
327 ? (int)$result['memory_limit_bytes'] : 0;
328 $this->setLowMemoryLimitAdminNotice($resultMemoryBytes);
329 }
330
331 if (function_exists('update_option')) {
332 update_option($this->phpEnvironmentProbeOptionName(), $result, false);
333 }
334
335 $this->phpEnvironmentProbeCache = $result;
336 return $result;
337 }
338
339 /**
340 * Contract alias: returns just the boolean used by the env-failure tests.
341 * Keeps the public surface compact for callers that only need the flag.
342 *
343 * @return bool
344 */
345 public function probeSetTimeLimitAvailability(): bool {
346 $probe = $this->probePhpEnvironmentForBuild();
347 return !empty($probe['set_time_limit_available']);
348 }
349
350 /**
351 * Contract alias: returns memory_limit in bytes (0 == unlimited) so
352 * callers can choose chunking vs. skip without re-parsing the ini value.
353 *
354 * @return int
355 */
356 public function probeMemoryLimitForS9(): int {
357 $probe = $this->probePhpEnvironmentForBuild();
358 $bytes = $probe['memory_limit_bytes'] ?? 0;
359 return is_numeric($bytes) ? (int)$bytes : 0;
360 }
361
362 /**
363 * Parse a php.ini-style memory size (`128M`, `1G`, `262144`, `-1`) into
364 * raw bytes. Returns 0 for "unlimited" (-1) or unparseable input.
365 *
366 * @param string $raw
367 * @return int
368 */
369 public function parsePhpMemoryLimitToBytes(string $raw): int {
370 $raw = trim($raw);
371 if ($raw === '' || $raw === '-1' || $raw === '0') {
372 return 0;
373 }
374 $unit = strtoupper(substr($raw, -1));
375 $num = (int)$raw;
376 if ($num <= 0) {
377 return 0;
378 }
379 switch ($unit) {
380 case 'G': return $num * 1073741824;
381 case 'M': return $num * 1048576;
382 case 'K': return $num * 1024;
383 default:
384 return is_numeric($raw) ? (int)$raw : 0;
385 }
386 }
387
388 /**
389 * @return array<int,string> Trimmed list of names from ini disable_functions.
390 */
391 public function phpDisabledFunctionsList(): array {
392 $raw = (string)ini_get('disable_functions');
393 if ($raw === '') {
394 return array();
395 }
396 $names = array_map('trim', explode(',', $raw));
397 return array_values(array_filter($names, function ($n) { return $n !== ''; }));
398 }
399
400 /**
401 * Surface a deduplicated admin notice when the host's memory_limit is
402 * below the recommended 128M floor. One per 24h per failure type, per
403 * the self-healing reliability rules in CLAUDE.md (notices on the
404 * plugin's own admin screen, never email, never wp-admin-wide banner).
405 *
406 * @param int $memoryBytes
407 * @return void
408 */
409 public function setLowMemoryLimitAdminNotice(int $memoryBytes): void {
410 $key = 'abj404_view_build_low_memory_limit_notice';
411 $payload = array(
412 'kind' => 'low_memory_limit',
413 'bytes' => $memoryBytes,
414 'recommended' => ABJ_404_Solution_ViewBuildConfig::PHP_MEMORY_LIMIT_RECOMMENDED_BYTES,
415 'message' => sprintf(
416 'Your PHP memory_limit (%s) is below the recommended 128M; '
417 . 'the redirect view rebuild may fail on large sites.',
418 $this->formatPhpMemoryBytesHuman($memoryBytes)
419 ),
420 'when' => $this->clock()->now(),
421 );
422 if (function_exists('set_transient')) {
423 // allow-cache-empty: PHP memory warning marker intentionally stores diagnostics, not query data.
424 set_transient(
425 $key,
426 $payload,
427 ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
428 );
429 } elseif (function_exists('update_option')) {
430 update_option($key, $payload, false);
431 }
432 }
433
434 /**
435 * Format a byte count as a php.ini-style suffix string for admin notices.
436 *
437 * @param int $bytes
438 * @return string
439 */
440 public function formatPhpMemoryBytesHuman(int $bytes): string {
441 if ($bytes <= 0) {
442 return 'unlimited';
443 }
444 if ($bytes >= 1073741824) {
445 $g = $bytes / 1073741824;
446 return ($g == (int)$g ? (string)(int)$g : number_format($g, 1)) . 'G';
447 }
448 if ($bytes >= 1048576) {
449 return (string)(int)round($bytes / 1048576) . 'M';
450 }
451 if ($bytes >= 1024) {
452 return (string)(int)round($bytes / 1024) . 'K';
453 }
454 return (string)$bytes;
455 }
456
457 /** @return void */
458 public function clearPhpEnvironmentProbeCache(): void {
459 $this->phpEnvironmentProbeCache = null;
460 $this->filesystemEnvironmentProbeCache = null;
461 if (function_exists('delete_option')) {
462 delete_option($this->phpEnvironmentProbeOptionName());
463 delete_option($this->filesystemEnvironmentProbeOptionName());
464 }
465 }
466
467 /** @return string */
468 public function filesystemEnvironmentProbeOptionName(): string {
469 return 'abj404_view_build_fs_env_probe';
470 }
471
472 /**
473 * Probe filesystem-side host constraints that can silently degrade or
474 * abort the staged view-build pipeline:
475 *
476 * - `open_basedir` set and our tmp/upload paths fall outside it: any
477 * `disk_free_space()` / fopen() against those paths returns false
478 * and the build cannot diagnose why.
479 * - `upload_tmp_dir` outside open_basedir: same constraint.
480 * - `@@tmpdir` (MySQL temp dir) on a near-full volume: S9 hits aggregate
481 * can fail with "table is full" or "No space left on device" when the
482 * temp file the optimizer materializes for the GROUP BY exceeds free
483 * bytes.
484 *
485 * Read-and-warn-only: never throws, never blocks the build. Logs at
486 * warning level (per defensive philosophy §8 -- infrastructure issues the
487 * plugin can degrade past) and surfaces a deduplicated admin notice so
488 * the operator can ask the host to widen open_basedir or clear disk space
489 * before the next build attempt.
490 *
491 * Filterable via `apply_filters('abj404_filesystem_env_probe', $defaults)`
492 * so tests and operators can simulate hardened-host scenarios without
493 * mutating the running PHP / MySQL process.
494 *
495 * @return array<string,mixed>
496 */
497 public function probeFilesystemEnvironmentForBuild(): array {
498 if (is_array($this->filesystemEnvironmentProbeCache)) {
499 return $this->filesystemEnvironmentProbeCache;
500 }
501
502 $rawOpenBasedir = (string)ini_get('open_basedir');
503 $rawUploadTmpDir = (string)ini_get('upload_tmp_dir');
504 $sysTmpDir = function_exists('sys_get_temp_dir') ? (string)sys_get_temp_dir() : '';
505
506 $pluginTmpCandidates = array_filter(array(
507 $sysTmpDir,
508 $rawUploadTmpDir,
509 ), function ($p) { return $p !== ''; });
510
511 $openBasedirPaths = $this->splitOpenBasedirPaths($rawOpenBasedir);
512
513 $tmpOutsideOpenBasedir = false;
514 $uploadTmpOutsideOpenBasedir = false;
515 if (!empty($openBasedirPaths)) {
516 foreach ($pluginTmpCandidates as $candidate) {
517 if (!$this->pathFallsWithinAny($candidate, $openBasedirPaths)) {
518 $tmpOutsideOpenBasedir = true;
519 break;
520 }
521 }
522 if ($rawUploadTmpDir !== ''
523 && !$this->pathFallsWithinAny($rawUploadTmpDir, $openBasedirPaths)) {
524 $uploadTmpOutsideOpenBasedir = true;
525 }
526 }
527
528 $tmpDirForCheck = $rawUploadTmpDir !== '' ? $rawUploadTmpDir : $sysTmpDir;
529 $tmpFreeBytes = -1;
530 if ($tmpDirForCheck !== ''
531 && function_exists('disk_free_space')
532 && (empty($openBasedirPaths) || $this->pathFallsWithinAny($tmpDirForCheck, $openBasedirPaths))) {
533 $prev = function_exists('error_reporting') ? error_reporting(0) : 0;
534 try {
535 $bytes = @disk_free_space($tmpDirForCheck);
536 $tmpFreeBytes = ($bytes === false) ? -1 : (int)$bytes;
537 } catch (\Throwable $e) { // allow-silent-catch: best-effort probe; reset error_reporting in finally
538 $tmpFreeBytes = -1;
539 }
540 if (function_exists('error_reporting')) {
541 error_reporting($prev);
542 }
543 }
544 $tmpDiskLow = ($tmpFreeBytes >= 0 && $tmpFreeBytes < ABJ_404_Solution_ViewBuildConfig::PHP_TMPDIR_FREE_FLOOR_BYTES);
545
546 $result = array(
547 'open_basedir_raw' => $rawOpenBasedir,
548 'open_basedir_paths' => $openBasedirPaths,
549 'upload_tmp_dir_raw' => $rawUploadTmpDir,
550 'sys_tmp_dir' => $sysTmpDir,
551 'tmp_outside_open_basedir' => $tmpOutsideOpenBasedir,
552 'upload_tmp_outside_open_basedir' => $uploadTmpOutsideOpenBasedir,
553 'tmp_free_bytes' => $tmpFreeBytes,
554 'tmp_disk_low' => $tmpDiskLow,
555 'tmp_disk_floor_bytes' => ABJ_404_Solution_ViewBuildConfig::PHP_TMPDIR_FREE_FLOOR_BYTES,
556 );
557
558 if (function_exists('apply_filters')) {
559 $filtered = apply_filters('abj404_filesystem_env_probe', $result);
560 if (is_array($filtered)) {
561 $result = array_merge($result, $filtered);
562 }
563 }
564
565 $warnings = array();
566 $resultOpenBasedirRaw = isset($result['open_basedir_raw']) && is_scalar($result['open_basedir_raw'])
567 ? (string)$result['open_basedir_raw'] : '';
568 $resultSysTmpDir = isset($result['sys_tmp_dir']) && is_scalar($result['sys_tmp_dir'])
569 ? (string)$result['sys_tmp_dir'] : '';
570 $resultUploadTmpDirRaw = isset($result['upload_tmp_dir_raw']) && is_scalar($result['upload_tmp_dir_raw'])
571 ? (string)$result['upload_tmp_dir_raw'] : '';
572 $resultTmpFreeBytes = isset($result['tmp_free_bytes']) && is_numeric($result['tmp_free_bytes'])
573 ? (int)$result['tmp_free_bytes'] : -1;
574 if (!empty($result['tmp_outside_open_basedir'])) {
575 $warnings[] = sprintf(
576 'open_basedir (%s) does not include the system temp directory (%s); '
577 . 'PHP-side temp file work may fail.',
578 $resultOpenBasedirRaw, $resultSysTmpDir
579 );
580 }
581 if (!empty($result['upload_tmp_outside_open_basedir'])) {
582 $warnings[] = sprintf(
583 'upload_tmp_dir (%s) is outside open_basedir (%s); ini upload paths cannot be probed.',
584 $resultUploadTmpDirRaw, $resultOpenBasedirRaw
585 );
586 }
587 if (!empty($result['tmp_disk_low'])) {
588 $warnings[] = sprintf(
589 'temp directory (%s) has %d bytes free (< %d MB floor); the S9 hits '
590 . 'aggregate or any MySQL temp materialization may fail with "No space left on device".',
591 $tmpDirForCheck,
592 $resultTmpFreeBytes,
593 (int)(ABJ_404_Solution_ViewBuildConfig::PHP_TMPDIR_FREE_FLOOR_BYTES / 1048576)
594 );
595 }
596 $result['warnings'] = $warnings;
597
598 foreach ($warnings as $w) {
599 $this->logger->warn('[staged] ' . $w);
600 }
601 if (!empty($warnings)) {
602 $this->setFilesystemEnvAdminNotice($result);
603 }
604
605 if (function_exists('update_option')) {
606 update_option($this->filesystemEnvironmentProbeOptionName(), $result, false);
607 }
608
609 $this->filesystemEnvironmentProbeCache = $result;
610 return $result;
611 }
612
613 /**
614 * Split a raw open_basedir value (`PATH_SEPARATOR`-delimited) into a
615 * trimmed list of absolute path prefixes. Empty input returns array().
616 *
617 * @param string $raw
618 * @return array<int,string>
619 */
620 public function splitOpenBasedirPaths(string $raw): array {
621 $raw = trim($raw);
622 if ($raw === '') { return array(); }
623 $sep = defined('PATH_SEPARATOR') ? PATH_SEPARATOR : ':';
624 $parts = array_map('trim', explode($sep, $raw));
625 return array_values(array_filter($parts, function ($p) { return $p !== ''; }));
626 }
627
628 /**
629 * True when $candidate falls within at least one of $allowed (string
630 * prefix match after normalizing trailing separators). Normalizes both
631 * sides via realpath() when available so symlinks resolve consistently.
632 *
633 * @param string $candidate
634 * @param array<int,string> $allowed
635 * @return bool
636 */
637 public function pathFallsWithinAny(string $candidate, array $allowed): bool {
638 if ($candidate === '' || empty($allowed)) { return true; }
639 $normCandidate = $this->normalizePathPrefix($candidate);
640 foreach ($allowed as $a) {
641 $normA = $this->normalizePathPrefix($a);
642 if ($normA === '') { continue; }
643 if (strncmp($normCandidate, $normA, strlen($normA)) === 0) {
644 return true;
645 }
646 }
647 return false;
648 }
649
650 /**
651 * Normalize a path for prefix comparison: realpath() if it exists, else
652 * trim trailing separators. Returns '' on bad input.
653 *
654 * @param string $path
655 * @return string
656 */
657 public function normalizePathPrefix(string $path): string {
658 $path = trim($path);
659 if ($path === '') { return ''; }
660 if (function_exists('realpath')) {
661 $real = @realpath($path);
662 if (is_string($real)) {
663 return rtrim($real, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
664 }
665 }
666 return rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
667 }
668
669 /**
670 * Surface a deduplicated admin notice describing filesystem-side host
671 * issues. One per 24h via transient (per CLAUDE.md self-healing rules).
672 *
673 * @param array<string,mixed> $probe
674 * @return void
675 */
676 public function setFilesystemEnvAdminNotice(array $probe): void {
677 $key = 'abj404_view_build_filesystem_env_notice';
678 $rawWarnings = isset($probe['warnings']) && is_array($probe['warnings']) ? $probe['warnings'] : array();
679 $stringWarnings = array();
680 foreach ($rawWarnings as $w) {
681 if (is_string($w)) { $stringWarnings[] = $w; }
682 }
683 $payload = array(
684 'kind' => 'filesystem_env',
685 'warnings' => $stringWarnings,
686 'message' => 'The 404 Solution view-build pipeline detected filesystem '
687 . 'host constraints that may degrade the next rebuild: '
688 . implode(' | ', $stringWarnings),
689 'when' => $this->clock()->now(),
690 );
691 if (function_exists('set_transient')) {
692 // allow-cache-empty: filesystem warning marker intentionally stores diagnostics, not query data.
693 set_transient(
694 $key,
695 $payload,
696 ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
697 );
698 } elseif (function_exists('update_option')) {
699 update_option($key, $payload, false);
700 }
701 }
702 }
703