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

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

157 lines 6.0 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 require_once __DIR__ . '/CrashBeacon.php';
8
9 /**
10 * Persists at most one pending ABJ_404_Solution_CrashBeacon to a small JSON file
11 * in the plugin's uploads dir.
12 *
13 * Why a file (not an option or a transient): a ~200-byte file_put_contents
14 * allocates far less than update_option (which can load the whole options table)
15 * during an OOM, AND a file in wp-content/uploads survives a plugin update (the
16 * plugin directory is replaced; uploads is not), which is what lets an
17 * every-request-OOM site be reported after it recovers.
18 *
19 * The fatal-handler (write) path must use ONLY a precomputed primitive path; it
20 * must not call wp_upload_dir()/options/the container during shutdown. Callers
21 * in the write path therefore construct this with the path cached at healthy
22 * boot in $GLOBALS['abj404_crash_beacon_path']. The healthy drain path may use
23 * forCurrentSite(), which resolves the path through WP normally.
24 */
25 class ABJ_404_Solution_CrashBeaconStore {
26
27 /** Predictable filename; the file contents are PII-redacted at rest, so a
28 * predictable path in a possibly-public uploads dir discloses nothing
29 * sensitive (consistent with the sibling abj404_debug.zip). */
30 const FILE_NAME = 'abj404_crash_beacon.json';
31
32 /** Refuse to read a file larger than this. A predictable-path file could be
33 * bloated by a corrupted write or an unrelated process; a real beacon is a
34 * few hundred bytes. */
35 const MAX_READ_BYTES = 8192;
36
37 /** @var string absolute path to the beacon file. */
38 private $filePath;
39
40 /**
41 * @param string $filePath absolute path to the beacon JSON file.
42 */
43 public function __construct(string $filePath) {
44 $this->filePath = $filePath;
45 }
46
47 /**
48 * Resolve the store for the current site on a HEALTHY request. Prefers the
49 * path cached at boot; falls back to resolving through WP. Do NOT use this in
50 * the fatal handler (it may call wp_upload_dir()).
51 *
52 * @return self
53 */
54 public static function forCurrentSite(): self {
55 $cached = isset($GLOBALS['abj404_crash_beacon_path']) && is_string($GLOBALS['abj404_crash_beacon_path'])
56 ? $GLOBALS['abj404_crash_beacon_path'] : '';
57 if ($cached !== '') {
58 return new self($cached);
59 }
60 $dir = function_exists('abj404_getUploadsDir') ? abj404_getUploadsDir() : '';
61 return new self(rtrim((string)$dir, '/\\') . DIRECTORY_SEPARATOR . self::FILE_NAME);
62 }
63
64 /** @return string */
65 public function filePath(): string {
66 return $this->filePath;
67 }
68
69 /** @return bool */
70 public function exists(): bool {
71 return @is_file($this->filePath);
72 }
73
74 /**
75 * Last-modified epoch seconds, or null if the file is absent/unstatable.
76 * Used by the drain to age-gate the discard of a corrupt/partial file so a
77 * file caught mid-write is not deleted before its writer finishes.
78 *
79 * @return int|null
80 */
81 public function modifiedAt(): ?int {
82 $m = @filemtime($this->filePath);
83 return $m === false ? null : (int)$m;
84 }
85
86 /**
87 * Write the beacon ONLY if no beacon file already exists. Uses fopen('xb'),
88 * an atomic create-only open, so two concurrent crashing requests cannot
89 * both write (first crash wins, race-safe) and so we never read+allocate an
90 * existing file during an OOM. All I/O is error-suppressed because this runs
91 * in the fatal handler where a warning must not escalate.
92 *
93 * @param ABJ_404_Solution_CrashBeacon $beacon
94 * @return bool true iff a new file was written.
95 */
96 public function recordIfAbsent(ABJ_404_Solution_CrashBeacon $beacon): bool {
97 $json = json_encode($beacon->toArray(), JSON_UNESCAPED_SLASHES);
98 if (!is_string($json)) {
99 return false;
100 }
101 $handle = @fopen($this->filePath, 'xb');
102 if ($handle === false) {
103 return false;
104 }
105 $written = @fwrite($handle, $json);
106 @fflush($handle);
107 @fclose($handle);
108 return $written !== false;
109 }
110
111 /**
112 * Read the pending beacon. Runs only on a healthy request, so failures are
113 * surfaced via status rather than silently suppressed.
114 *
115 * Status values:
116 * 'absent' no file.
117 * 'oversized' file exceeds MAX_READ_BYTES (refused).
118 * 'unreadable' file present but could not be read or was empty (possibly mid-write).
119 * 'future' a newer beacon_schema_version: LEAVE IT (a compatible version drains it).
120 * 'corrupt' present, readable, but not a valid known-version beacon.
121 * 'ok' parsed.
122 *
123 * @return array{beacon: ABJ_404_Solution_CrashBeacon|null, status: string}
124 */
125 public function read(): array {
126 if (!@is_file($this->filePath)) {
127 return array('beacon' => null, 'status' => 'absent');
128 }
129 $size = @filesize($this->filePath);
130 if ($size === false || $size > self::MAX_READ_BYTES) {
131 return array('beacon' => null, 'status' => 'oversized');
132 }
133 $raw = @file_get_contents($this->filePath);
134 if (!is_string($raw) || $raw === '') {
135 return array('beacon' => null, 'status' => 'unreadable');
136 }
137 $decoded = json_decode($raw, true);
138 $beacon = ABJ_404_Solution_CrashBeacon::fromArray($decoded);
139 if ($beacon instanceof ABJ_404_Solution_CrashBeacon) {
140 return array('beacon' => $beacon, 'status' => 'ok');
141 }
142 if (is_array($decoded) && isset($decoded['beacon_schema_version'])
143 && is_scalar($decoded['beacon_schema_version'])
144 && (int)$decoded['beacon_schema_version'] > ABJ_404_Solution_CrashBeacon::SCHEMA_VERSION) {
145 return array('beacon' => null, 'status' => 'future');
146 }
147 return array('beacon' => null, 'status' => 'corrupt');
148 }
149
150 /** Remove the pending beacon file (idempotent). @return void */
151 public function clear(): void {
152 if (@is_file($this->filePath)) {
153 @unlink($this->filePath);
154 }
155 }
156 }
157