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 / OpcacheGenerationProbe.php

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

249 lines 10.2 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 * Are the opcodes this request is executing the same generation as the files
9 * on disk? (Bruno timeout cause matrix, cause D "stale or mixed code".)
10 *
11 * Disk hashes prove what the filesystem holds. They cannot prove which opcodes
12 * PHP actually ran: an opcode cache with `validate_timestamps` off, or one
13 * that recompiled some files and not others across a deploy, will happily run
14 * last release's bytecode for a file whose disk contents are current. A
15 * differing POSITIVE OPcache timestamp is the direct evidence of that split,
16 * and it is the only evidence available from inside the request.
17 *
18 * One read per request, shared by every caller. `opcache_get_status(true)`
19 * walks every cached script on the host, which makes it the most expensive
20 * probe in the diagnostic path; calling it once here and passing this object
21 * around is why ABJ_404_Solution_RequestEnvironmentFingerprint (two detailed
22 * file fingerprints) and ABJ_404_Solution_DiagnosticModuleManifest (the whole
23 * diagnostic module set) can both reconcile against it without paying twice.
24 *
25 * Three-valued throughout: "cached and matching", "cached and stale", and
26 * "unknown" are different findings, and an unavailable status API must never
27 * collapse into "not cached" -- that would read as a fresh deploy on every
28 * request of every host with `opcache.restrict_api` set.
29 */
30 final class ABJ_404_Solution_OpcacheGenerationProbe {
31
32 /** @var array<string, mixed>|null Per-script metadata, or null when unavailable. */
33 private $scripts;
34
35 /** @var array<string, mixed> */
36 private $summary;
37
38 /**
39 * @param array<string, mixed>|null $scripts
40 * @param array<string, mixed> $summary
41 */
42 private function __construct(?array $scripts, array $summary) {
43 $this->scripts = $scripts;
44 $this->summary = $summary;
45 }
46
47 /** Read the opcode cache's state for this request. */
48 public static function read(): self {
49 $summary = self::unavailableSummary();
50 $restrictApi = ini_get('opcache.restrict_api');
51 $apiRestricted = function_exists('abj404_opcache_api_is_restricted')
52 ? abj404_opcache_api_is_restricted($restrictApi, __FILE__)
53 : (is_string($restrictApi) && trim($restrictApi) !== '');
54 if ($apiRestricted) {
55 $summary['reason'] = 'opcache-api-restricted';
56 return new self(null, $summary);
57 }
58 if (!ABJ_404_Solution_PhpRuntimeCapabilityAdapter::isFunctionAvailable('opcache_get_status')) {
59 return new self(null, $summary);
60 }
61
62 $status = ABJ_404_Solution_OpcacheAdapter::status(true);
63 if (!is_array($status) || (array_key_exists('opcache_enabled', $status) && !$status['opcache_enabled'])) {
64 return new self(null, $summary);
65 }
66 return new self(self::stringKeyed($status['scripts'] ?? null),
67 self::summaryFromStatus($summary, $status));
68 }
69
70 /**
71 * Build a probe over a known per-script map. The named constructor the
72 * real read() delegates to, and the seam a test uses to drive a specific
73 * mixed-generation scenario without needing a host whose opcode cache is
74 * in that state.
75 *
76 * @param array<string, mixed>|null $scripts
77 */
78 public static function forScripts(?array $scripts): self {
79 $summary = self::unavailableSummary();
80 if ($scripts !== null) {
81 $summary['reason'] = 'available';
82 }
83 return new self($scripts === null ? null : self::stringKeyed($scripts), $summary);
84 }
85
86 /**
87 * Constant-cost OPcache evidence for one boundary module.
88 *
89 * The full request_start probe walks the host's complete script map.
90 * Early boot checkpoints cannot pay that unbounded cost, so they record
91 * only whether this one module is cached plus the timestamp-validation
92 * policy that controls its freshness. The compiled build marker beside
93 * this snapshot provides the exact generation comparison.
94 *
95 * @return array<string, bool|int|string|null>
96 */
97 public static function boundarySnapshot(string $path): array {
98 $reason = 'opcache-unavailable';
99 $restrictApi = ini_get('opcache.restrict_api');
100 $apiRestricted = function_exists('abj404_opcache_api_is_restricted')
101 ? abj404_opcache_api_is_restricted($restrictApi, __FILE__)
102 : (is_string($restrictApi) && trim($restrictApi) !== '');
103 if ($apiRestricted) {
104 $reason = 'opcache-api-restricted';
105 }
106 $cached = null;
107 if (!$apiRestricted && function_exists('opcache_is_script_cached')) {
108 $cached = @opcache_is_script_cached($path);
109 $reason = 'available';
110 }
111 return array(
112 'reason' => $reason,
113 'cached' => $cached,
114 'validate_timestamps' => self::iniBoolean(ini_get('opcache.validate_timestamps')),
115 'revalidate_freq' => self::numericInteger(ini_get('opcache.revalidate_freq')),
116 );
117 }
118
119 /**
120 * The per-script map with its keys made string-typed. Array keys are int
121 * or string, and a file path that looks numeric ("/8080.php" cannot, but a
122 * relative "8080" key from a filtered value can) would otherwise arrive as
123 * an int and never match a path lookup.
124 *
125 * @param mixed $scripts
126 * @return array<string, mixed>|null
127 */
128 private static function stringKeyed($scripts): ?array {
129 if (!is_array($scripts)) {
130 return null;
131 }
132 $keyed = array();
133 foreach ($scripts as $path => $metadata) {
134 $keyed[(string)$path] = $metadata;
135 }
136 return $keyed;
137 }
138
139 /**
140 * The per-request summary for the journal.
141 *
142 * @return array<string, mixed>
143 */
144 public function summary(): array {
145 return $this->summary;
146 }
147
148 /** Whether per-script state is available at all. False means every answer is "unknown". */
149 public function hasPerScriptData(): bool {
150 return $this->scripts !== null;
151 }
152
153 /**
154 * This file's opcode-cache state.
155 *
156 * `cached` is null (not false) when there is no per-script data, so an
157 * unavailable status API is never reported as an uncached file.
158 * `matches_file` is null when the cache reports a zero or absent timestamp
159 * (validate_timestamps off, so there is nothing to compare) rather than
160 * false, which would fire on every request of every production host.
161 *
162 * @return array{cached: bool|null, timestamp: int|null, matches_file: bool|null}
163 */
164 public function stateFor(string $path, ?int $mtime): array {
165 if ($this->scripts === null) {
166 return array('cached' => null, 'timestamp' => null, 'matches_file' => null);
167 }
168 $metadata = $path !== '' ? ($this->scripts[$path] ?? null) : null;
169 if (!is_array($metadata)) {
170 return array('cached' => false, 'timestamp' => null, 'matches_file' => null);
171 }
172 $timestamp = isset($metadata['timestamp']) && is_numeric($metadata['timestamp'])
173 ? (int)$metadata['timestamp'] : null;
174 return array(
175 'cached' => true,
176 'timestamp' => $timestamp,
177 'matches_file' => ($timestamp !== null && $timestamp > 0 && $mtime !== null)
178 ? ($timestamp === $mtime) : null,
179 );
180 }
181
182 /**
183 * Annotate loaded-file fingerprints with their opcode-cache state, keyed
184 * by the `path` and `mtime` each entry already carries.
185 *
186 * @param array<int, array<string, mixed>> $files
187 * @return array<int, array<string, mixed>>
188 */
189 public function annotate(array $files): array {
190 foreach ($files as &$file) {
191 $state = $this->stateFor(
192 is_string($file['path'] ?? null) ? $file['path'] : '',
193 is_numeric($file['mtime'] ?? null) ? (int)$file['mtime'] : null);
194 $file['opcache_cached'] = $state['cached'];
195 $file['opcache_timestamp'] = $state['timestamp'];
196 $file['opcache_timestamp_matches_file'] = $state['matches_file'];
197 }
198 unset($file);
199 return $files;
200 }
201
202 /** @return array<string, mixed> */
203 private static function unavailableSummary(): array {
204 return array(
205 'reason' => 'opcache-unavailable',
206 'validate_timestamps' => self::iniBoolean(ini_get('opcache.validate_timestamps')),
207 'revalidate_freq' => self::numericInteger(ini_get('opcache.revalidate_freq')),
208 'restart_pending' => null,
209 'restart_in_progress' => null,
210 'start_time' => null,
211 'last_restart_time' => null,
212 'restart_counts' => array('oom' => null, 'hash' => null, 'manual' => null),
213 );
214 }
215
216 /**
217 * @param array<string, mixed> $summary
218 * @param array<string, mixed> $status
219 * @return array<string, mixed>
220 */
221 private static function summaryFromStatus(array $summary, array $status): array {
222 $statistics = is_array($status['opcache_statistics'] ?? null) ? $status['opcache_statistics'] : array();
223 $summary['reason'] = 'available';
224 $summary['restart_pending'] = isset($status['restart_pending']) ? (bool)$status['restart_pending'] : null;
225 $summary['restart_in_progress'] = isset($status['restart_in_progress']) ? (bool)$status['restart_in_progress'] : null;
226 $summary['start_time'] = self::numericInteger($statistics['start_time'] ?? null);
227 $summary['last_restart_time'] = self::numericInteger($statistics['last_restart_time'] ?? null);
228 $summary['restart_counts'] = array(
229 'oom' => self::numericInteger($statistics['oom_restarts'] ?? null),
230 'hash' => self::numericInteger($statistics['hash_restarts'] ?? null),
231 'manual' => self::numericInteger($statistics['manual_restarts'] ?? null),
232 );
233 return $summary;
234 }
235
236 /** @param mixed $value */
237 private static function iniBoolean($value): ?bool {
238 if ($value === false || $value === null || $value === '' || !is_scalar($value)) {
239 return null;
240 }
241 return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
242 }
243
244 /** @param mixed $value */
245 private static function numericInteger($value): ?int {
246 return is_numeric($value) ? (int)$value : null;
247 }
248 }
249