PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / diagnostics / HostPressureSampler.php

HostPressureSampler.php in 404 Solution trunk, at includes/diagnostics/HostPressureSampler.php

497 lines 19.6 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 * Samples host-level pressure visible to an unprivileged PHP worker.
9 *
10 * Each probe returns either a named available result or a named unavailable
11 * reason. Empty fields are forbidden because an absent counter must remain
12 * distinguishable from a healthy zero when a request dies under transient
13 * LiteSpeed or CloudLinux pressure.
14 */
15 final class ABJ_404_Solution_HostPressureSampler {
16
17 /** @var array<string, array<string, mixed>> */
18 private static $sameUidProcessesByRequest = array();
19
20 /**
21 * Capture host pressure while paying the process-wide procfs walk once
22 * for each request. Cheap counters remain fresh on every checkpoint.
23 *
24 * @param string $requestScope Stable identity shared by captures from one request.
25 * @return array<string, mixed>
26 */
27 public static function capture(string $requestScope = ''): array {
28 $procRoot = '/proc';
29 if (function_exists('apply_filters')) {
30 try {
31 $filtered = apply_filters('abj404_host_pressure_proc_root', $procRoot);
32 $procRoot = is_string($filtered) && $filtered !== '' ? $filtered : $procRoot;
33 } catch (Throwable $e) {
34 self::reportFailure('proc-root filter failed: ' . get_class($e) . ' code=' .
35 $e->getCode() . ' message=' . $e->getMessage());
36 }
37 }
38 $procRoot = rtrim($procRoot, '/\\');
39 $environment = self::processEnvironment();
40
41 return array(
42 'sys_loadavg' => self::systemLoadAverage(),
43 'proc_loadavg' => self::procLoadAverage($procRoot . '/loadavg'),
44 'proc_self_status' => self::procSelfStatus($procRoot . '/self/status'),
45 'runtime_identity' => self::runtimeIdentity(),
46 'proc_self_limits' => self::procSelfLimits($procRoot . '/self/limits'),
47 'proc_self_cgroup' => self::procSelfCgroup($procRoot . '/self/cgroup'),
48 'same_uid_processes' => self::sameUidProcesses($procRoot, $requestScope),
49 'cloudlinux_lve_server_vars' => ABJ_404_Solution_HostServerCounterProbe::capture(
50 '/^(?:LVE_|CLOUDLINUX_)/i',
51 $environment
52 ),
53 'litespeed_server_vars' => ABJ_404_Solution_HostServerCounterProbe::capture(
54 '/^(?:LSAPI_|LITESPEED_|LSWS_)/i',
55 $environment
56 ),
57 'filesystem_quota_probes' => ABJ_404_Solution_HostFilesystemPressureProbe::capture(),
58 );
59 }
60
61 /**
62 * PHPUnit workers simulate multiple requests in one PHP process.
63 */
64 public static function resetForTests(): void {
65 self::$sameUidProcessesByRequest = array();
66 }
67
68 /**
69 * Keep the first full host-pressure snapshot for each request, then omit
70 * probes that are byte-identical to that request-local base. Changed and
71 * unencodable probes stay inline, so any selected later record remains
72 * self-describing without a previous-record reference chain.
73 *
74 * @param array<string, mixed> $record
75 * @param array<string, array<string, string>> $snapshotByRequest
76 * @return array<string, mixed>
77 */
78 public static function compactRepeatedHostPressureSnapshots(
79 array $record,
80 array &$snapshotByRequest
81 ): array {
82 $requestId = is_string($record['request_id'] ?? null) ? $record['request_id'] : '';
83 $hostPressure = $record['host_pressure'] ?? null;
84 if ($requestId === '' || !is_array($hostPressure)) {
85 return $record;
86 }
87 $namedHostPressure = array();
88 foreach ($hostPressure as $probeName => $probe) {
89 if (!is_string($probeName)) {
90 return $record;
91 }
92 $namedHostPressure[$probeName] = $probe;
93 }
94 $hostPressure = $namedHostPressure;
95
96 if (!array_key_exists($requestId, $snapshotByRequest)) {
97 $snapshotByRequest[$requestId] = self::hostPressureFingerprints($hostPressure);
98 return $record;
99 }
100
101 $unchanged = array();
102 foreach ($hostPressure as $probeName => $probe) {
103 $encoded = json_encode($probe, JSON_UNESCAPED_SLASHES);
104 if (!is_string($encoded)) {
105 continue;
106 }
107 $fingerprint = hash('sha256', $encoded);
108 if (($snapshotByRequest[$requestId][$probeName] ?? '') !== $fingerprint) {
109 continue;
110 }
111 unset($hostPressure[$probeName]);
112 $unchanged[] = $probeName;
113 }
114 if ($unchanged === array()) {
115 return $record;
116 }
117
118 if ($hostPressure === array()) {
119 unset($record['host_pressure']);
120 } else {
121 $record['host_pressure'] = $hostPressure;
122 }
123 $record['host_pressure_unchanged'] = $unchanged;
124 return $record;
125 }
126
127 /**
128 * @param array<string, mixed> $hostPressure
129 * @return array<string, string>
130 */
131 private static function hostPressureFingerprints(array $hostPressure): array {
132 $fingerprints = array();
133 foreach ($hostPressure as $probeName => $probe) {
134 $encoded = json_encode($probe, JSON_UNESCAPED_SLASHES);
135 if (is_string($encoded)) {
136 $fingerprints[$probeName] = hash('sha256', $encoded);
137 }
138 }
139 return $fingerprints;
140 }
141
142 /** @return array<string, mixed> */
143 private static function systemLoadAverage(): array {
144 if (!ABJ_404_Solution_PhpRuntimeCapabilityAdapter::isFunctionAvailable('sys_getloadavg')) {
145 return self::unavailable('function_unavailable', array(
146 'function_exists(sys_getloadavg)' => 'false',
147 ));
148 }
149 $load = ABJ_404_Solution_PhpRuntimeCapabilityAdapter::systemLoadAverage();
150 if (!is_array($load) || count($load) < 3) {
151 return self::unavailable('invalid_result', array(
152 'sys_getloadavg()' => 'invalid_result',
153 ));
154 }
155 return array(
156 'status' => 'available',
157 'one_min' => (float)$load[0],
158 'five_min' => (float)$load[1],
159 'fifteen_min' => (float)$load[2],
160 );
161 }
162
163 /** @return array<string, mixed> */
164 private static function procLoadAverage(string $path): array {
165 $raw = self::readProbeFile($path);
166 if ($raw === null) {
167 return self::unavailable('not_readable', array($path => 'not_readable'));
168 }
169 $parts = preg_split('/\s+/', trim($raw));
170 $processes = is_array($parts) && isset($parts[3]) ? explode('/', (string)$parts[3], 2) : array();
171 if (!is_array($parts) || count($parts) < 5 || !is_numeric($parts[0])
172 || !is_numeric($parts[1]) || !is_numeric($parts[2])
173 || count($processes) !== 2 || !is_numeric($processes[0])
174 || !is_numeric($processes[1]) || !is_numeric($parts[4])) {
175 return self::unavailable('invalid_format', array($path => 'invalid_format'));
176 }
177 return array(
178 'status' => 'available',
179 'one_min' => (float)$parts[0],
180 'five_min' => (float)$parts[1],
181 'fifteen_min' => (float)$parts[2],
182 'running_processes' => (int)$processes[0],
183 'total_processes' => (int)$processes[1],
184 'last_pid' => (int)$parts[4],
185 );
186 }
187
188 /** @return array<string, mixed> */
189 private static function procSelfStatus(string $path): array {
190 $raw = self::readProbeFile($path);
191 if ($raw === null) {
192 return self::unavailable('not_readable', array($path => 'not_readable'));
193 }
194 $wanted = array_flip(array(
195 'State', 'Threads', 'VmPeak', 'VmSize', 'VmRSS', 'VmSwap',
196 'voluntary_ctxt_switches', 'nonvoluntary_ctxt_switches',
197 ));
198 $values = array();
199 foreach (preg_split('/\R/', $raw) ?: array() as $line) {
200 $pair = explode(':', (string)$line, 2);
201 $name = $pair[0] ?? '';
202 if (isset($wanted[$name]) && isset($pair[1])) {
203 $values[$name] = substr(trim($pair[1]), 0, 64);
204 }
205 }
206 if ($values === array()) {
207 return self::unavailable('no_supported_fields', array($path => 'no_supported_fields'));
208 }
209 return array('status' => 'available', 'values' => $values);
210 }
211
212 /** @return array<string, mixed> */
213 private static function runtimeIdentity(): array {
214 $sapi = php_sapi_name();
215 $serverSoftware = $_SERVER['SERVER_SOFTWARE'] ?? null;
216 $serverSoftwareProbe = self::unavailable('not_present', array(
217 '$_SERVER[SERVER_SOFTWARE]' => 'not_present',
218 ), array('php_sapi_name' => php_sapi_name()));
219 if (array_key_exists('SERVER_SOFTWARE', $_SERVER)) {
220 $readable = self::readableScalar($serverSoftware, 128);
221 $serverSoftwareProbe = $readable === null
222 ? self::unavailable('not_readable', array(
223 '$_SERVER[SERVER_SOFTWARE]' => 'not_readable',
224 ), array('php_sapi_name' => php_sapi_name()))
225 : array('status' => 'available', 'value' => self::serverSoftwareClass($readable));
226 }
227 return array(
228 'php_sapi_name' => array('status' => 'available', 'value' => $sapi),
229 'server_software' => $serverSoftwareProbe,
230 );
231 }
232
233 private static function serverSoftwareClass(string $raw): string {
234 $hostMarker = stripos($raw, ' Server at ');
235 $withoutHost = $hostMarker === false ? $raw : substr($raw, 0, $hostMarker);
236 return substr(trim($withoutHost), 0, 100);
237 }
238
239 /** @return array<string, mixed> */
240 private static function processEnvironment(): array {
241 $environment = getenv();
242 $probe = array('status' => 'available', 'values' => $environment);
243 if (!function_exists('apply_filters')) {
244 return $probe;
245 }
246 try {
247 $filtered = apply_filters(
248 'abj404_host_pressure_environment',
249 $probe['values']
250 );
251 } catch (Throwable $e) {
252 self::reportFailure('environment filter failed: ' . get_class($e) . ' code=' .
253 $e->getCode() . ' message=' . $e->getMessage());
254 return self::unavailable('environment_filter_failed', array(
255 'getenv()' => 'available',
256 'apply_filters(abj404_host_pressure_environment)' => 'exception:' . get_class($e),
257 ));
258 }
259 return is_array($filtered)
260 ? array('status' => 'available', 'values' => $filtered)
261 : self::unavailable('environment_filter_invalid', array(
262 'getenv()' => 'available',
263 'apply_filters(abj404_host_pressure_environment)' => 'not_array',
264 ));
265 }
266
267 /** @return array<string, mixed> */
268 private static function procSelfLimits(string $path): array {
269 $raw = self::readProbeFile($path);
270 if ($raw === null) {
271 return self::unavailable('not_readable', array($path => 'not_readable'));
272 }
273 $names = array(
274 'Max processes' => 'RLIMIT_NPROC',
275 'Max address space' => 'RLIMIT_AS',
276 'Max open files' => 'RLIMIT_NOFILE',
277 );
278 $limits = array_fill_keys(array_values($names), self::unavailable('not_reported', array(
279 $path => 'limit_not_reported',
280 )));
281 $recognized = false;
282 foreach (preg_split('/\R/', $raw) ?: array() as $line) {
283 foreach ($names as $label => $constant) {
284 if (strpos((string)$line, $label) !== 0) {
285 continue;
286 }
287 $recognized = true;
288 $parts = preg_split('/\s+/', trim(substr((string)$line, strlen($label))));
289 if (!is_array($parts) || count($parts) !== 3
290 || preg_match('/^(?:unlimited|\d+)$/', $parts[0]) !== 1
291 || preg_match('/^(?:unlimited|\d+)$/', $parts[1]) !== 1
292 || preg_match('/^[a-z]+$/i', $parts[2]) !== 1) {
293 $limits[$constant] = self::unavailable('invalid_format', array(
294 $path . ':' . $label => 'invalid_format',
295 ));
296 continue;
297 }
298 $limits[$constant] = array(
299 'status' => 'available',
300 'soft_limit' => $parts[0],
301 'hard_limit' => $parts[1],
302 'unit' => $parts[2],
303 );
304 }
305 }
306 if (!$recognized) {
307 $reason = trim($raw) === '' ? 'empty_file' : 'no_supported_limits';
308 return self::unavailable($reason, array($path => $reason));
309 }
310 return array('status' => 'available', 'limits' => $limits);
311 }
312
313 /** @return array<string, mixed> */
314 private static function procSelfCgroup(string $path): array {
315 $raw = self::readProbeFile($path);
316 if ($raw === null) {
317 return self::unavailable('not_readable', array($path => 'not_readable'));
318 }
319 $memberships = array();
320 $invalidLines = 0;
321 foreach (preg_split('/\R/', trim($raw)) ?: array() as $line) {
322 if ($line === '') {
323 continue;
324 }
325 $parts = explode(':', (string)$line, 3);
326 $controllers = count($parts) === 3 && $parts[1] !== '' ? explode(',', $parts[1]) : array();
327 $controllersValid = array_filter($controllers, static function ($controller): bool {
328 return preg_match('/^[a-z0-9_.=-]+$/i', (string)$controller) !== 1;
329 }) === array();
330 if (count($parts) !== 3 || preg_match('/^\d+$/', $parts[0]) !== 1
331 || !$controllersValid || strpos($parts[2], '/') !== 0) {
332 $invalidLines++;
333 continue;
334 }
335 $memberships[] = array(
336 'hierarchy_id' => $parts[0],
337 'controllers' => $controllers,
338 'path' => self::readableScalar($parts[2], 256),
339 );
340 }
341 if ($memberships === array()) {
342 $reason = trim($raw) === '' ? 'empty_file' : 'invalid_format';
343 return self::unavailable($reason, array($path => $reason));
344 }
345 return array(
346 'status' => 'available',
347 'memberships' => $memberships,
348 'invalid_lines' => $invalidLines,
349 );
350 }
351
352 /** @return array<string, mixed> */
353 private static function sameUidProcesses(string $procRoot, string $requestScope): array {
354 $cacheKey = hash('sha256', $requestScope . "\0" . $procRoot);
355 if (array_key_exists($cacheKey, self::$sameUidProcessesByRequest)) {
356 return self::$sameUidProcessesByRequest[$cacheKey];
357 }
358 if (!is_dir($procRoot) || !is_readable($procRoot)) {
359 return self::unavailable('proc_root_not_readable', array(
360 $procRoot => 'not_readable_directory',
361 ));
362 }
363 $selfStatus = self::readProbeFile($procRoot . '/self/status');
364 if ($selfStatus === null) {
365 return self::unavailable('self_status_not_readable', array(
366 $procRoot . '/self/status' => 'not_readable',
367 ));
368 }
369 $effectiveUid = self::effectiveUidFromStatus($selfStatus);
370 if ($effectiveUid === null) {
371 return self::unavailable('self_effective_uid_unavailable', array(
372 $procRoot . '/self/status:Uid' => 'missing_or_invalid',
373 ));
374 }
375 $selfPid = self::numericStatusField($selfStatus, 'Pid');
376 if ($selfPid === null) {
377 return self::unavailable('self_pid_unavailable', array(
378 $procRoot . '/self/status:Pid' => 'missing_or_invalid',
379 ));
380 }
381 $entries = @scandir($procRoot);
382 if (!is_array($entries)) {
383 return self::unavailable('proc_root_scan_failed', array(
384 'scandir(' . $procRoot . ')' => 'failed',
385 ));
386 }
387 $siblings = 0;
388 $readable = 0;
389 $unreadable = 0;
390 foreach ($entries as $entry) {
391 if (preg_match('/^\d+$/', (string)$entry) !== 1
392 || !is_dir($procRoot . '/' . $entry)) {
393 continue;
394 }
395 $status = self::readProbeFile($procRoot . '/' . $entry . '/status');
396 $uid = $status === null ? null : self::effectiveUidFromStatus($status);
397 if ($uid === null) {
398 $unreadable++;
399 continue;
400 }
401 $readable++;
402 if ($uid === $effectiveUid && $entry !== $selfPid) {
403 $siblings++;
404 }
405 }
406 if ($readable === 0) {
407 $result = self::unavailable('no_readable_process_statuses', array(
408 $procRoot . '/[pid]/status' => 'none_readable',
409 ));
410 self::$sameUidProcessesByRequest[$cacheKey] = $result;
411 return $result;
412 }
413 $result = array(
414 'status' => 'available',
415 'effective_uid' => $effectiveUid,
416 'sibling_count' => $siblings,
417 'readable_processes' => $readable,
418 'unreadable_processes' => $unreadable,
419 );
420 self::$sameUidProcessesByRequest[$cacheKey] = $result;
421 return $result;
422 }
423
424 private static function effectiveUidFromStatus(string $raw): ?string {
425 foreach (preg_split('/\R/', $raw) ?: array() as $line) {
426 if (strpos((string)$line, 'Uid:') !== 0) {
427 continue;
428 }
429 $uids = preg_split('/\s+/', trim(substr((string)$line, 4)));
430 return is_array($uids) && isset($uids[1]) && preg_match('/^\d+$/', $uids[1]) === 1
431 ? $uids[1]
432 : null;
433 }
434 return null;
435 }
436
437 private static function numericStatusField(string $raw, string $field): ?string {
438 foreach (preg_split('/\R/', $raw) ?: array() as $line) {
439 if (strpos((string)$line, $field . ':') !== 0) {
440 continue;
441 }
442 $value = trim(substr((string)$line, strlen($field) + 1));
443 return preg_match('/^\d+$/', $value) === 1 ? $value : null;
444 }
445 return null;
446 }
447
448 /** @param mixed $value */
449 private static function readableScalar($value, int $maxLength): ?string {
450 if (!is_scalar($value)) {
451 return null;
452 }
453 $string = is_bool($value) ? ($value ? '1' : '0') : (string)$value;
454 $ascii = preg_replace('/[^\x20-\x7E]/', '?', $string);
455 if (!is_string($ascii)) {
456 return null;
457 }
458 $truncated = substr($ascii, 0, $maxLength);
459 return is_string($truncated) ? $truncated : null;
460 }
461
462 private static function readProbeFile(string $path): ?string {
463 if (!@is_readable($path)) {
464 return null;
465 }
466 $raw = @file_get_contents($path);
467 return is_string($raw) ? $raw : null;
468 }
469
470 /**
471 * @param array<string, string> $attemptedPaths
472 * @param array<string, scalar|null> $context
473 * @return array<string, mixed>
474 */
475 private static function unavailable(
476 string $reason,
477 array $attemptedPaths,
478 array $context = array()
479 ): array {
480 $reading = array(
481 'status' => 'unavailable',
482 'reason' => $reason,
483 'attempted_paths' => $attemptedPaths,
484 );
485 if ($context !== array()) {
486 $reading['context'] = $context;
487 }
488 return $reading;
489 }
490
491 private static function reportFailure(string $message): void {
492 if (function_exists('abj404_logPhpFallback')) {
493 abj404_logPhpFallback('host-pressure', $message);
494 }
495 }
496 }
497