1: <?php declare(strict_types=1);
2:
3: namespace Salient\Core;
4:
5: use Salient\Contract\Core\Exception\ExceptionInterface;
6: use Salient\Contract\Core\FacadeAwareInterface;
7: use Salient\Contract\Core\FacadeInterface;
8: use Salient\Contract\Core\Instantiable;
9: use Salient\Core\Concern\UnloadsFacades;
10: use Salient\Core\Facade\Console;
11: use Salient\Utility\File;
12: use Salient\Utility\Regex;
13: use ErrorException;
14: use LogicException;
15: use Throwable;
16:
17: /**
18: * Handle errors and uncaught exceptions
19: *
20: * @implements FacadeAwareInterface<FacadeInterface<self>>
21: */
22: final class ErrorHandler implements FacadeAwareInterface, Instantiable
23: {
24: /** @use UnloadsFacades<FacadeInterface<self>> */
25: use UnloadsFacades;
26:
27: private const DEFAULT_EXIT_STATUS = 16;
28:
29: private const FATAL_ERRORS = \E_ERROR
30: | \E_PARSE
31: | \E_CORE_ERROR
32: | \E_CORE_WARNING
33: | \E_COMPILE_ERROR
34: | \E_COMPILE_WARNING;
35:
36: /**
37: * [ [ Path regex, error levels ], ... ]
38: *
39: * @var array<array{string,int}>
40: */
41: private array $Silenced = [];
42:
43: private int $ExitStatus = 0;
44: private bool $IsRegistered = false;
45: private bool $ShutdownIsRegistered = false;
46: private bool $IsShuttingDown = false;
47: private bool $IsShuttingDownOnFatalError = false;
48: private bool $IsShuttingDownOnUncaughtException = false;
49: private bool $IsShuttingDownOnExitSignal = false;
50:
51: /**
52: * Creates a new ErrorHandler object
53: */
54: public function __construct() {}
55:
56: /**
57: * Register error, exception and shutdown handlers
58: *
59: * @return $this
60: */
61: public function register()
62: {
63: if ($this->IsRegistered) {
64: return $this;
65: }
66:
67: set_exception_handler([$this, 'handleException']);
68: set_error_handler([$this, 'handleError']);
69:
70: $this->IsRegistered = true;
71:
72: if ($this->ShutdownIsRegistered) {
73: return $this;
74: }
75:
76: register_shutdown_function([$this, 'handleShutdown']);
77:
78: $this->ShutdownIsRegistered = true;
79:
80: return $this;
81: }
82:
83: /**
84: * Check if error, exception and shutdown handlers are registered
85: */
86: public function isRegistered(): bool
87: {
88: return $this->IsRegistered;
89: }
90:
91: /**
92: * Check if the running script is terminating
93: */
94: public function isShuttingDown(): bool
95: {
96: return $this->IsShuttingDown;
97: }
98:
99: /**
100: * Check if the running script is terminating after a fatal error or
101: * uncaught exception
102: */
103: public function isShuttingDownOnError(): bool
104: {
105: return $this->IsShuttingDownOnFatalError
106: || $this->IsShuttingDownOnUncaughtException
107: || $this->IsShuttingDownOnExitSignal;
108: }
109:
110: /**
111: * Get the exit status of the running script if it is terminating
112: */
113: public function getExitStatus(): int
114: {
115: if (!$this->IsShuttingDown) {
116: throw new LogicException('Script is not terminating');
117: }
118: return $this->ExitStatus;
119: }
120:
121: /**
122: * Deregister previously registered error and exception handlers
123: *
124: * @return $this
125: */
126: public function deregister()
127: {
128: if ($this->IsRegistered) {
129: restore_error_handler();
130: restore_exception_handler();
131: }
132:
133: $this->unloadFacades();
134:
135: return $this;
136: }
137:
138: /**
139: * Silence errors in a file or directory
140: *
141: * @return $this
142: */
143: public function silencePath(string $path, int $levels = \E_STRICT | \E_DEPRECATED | \E_USER_DEPRECATED)
144: {
145: // Ignore paths that don't exist
146: if (!file_exists($path)) {
147: return $this;
148: }
149:
150: $path = File::realpath($path);
151: $this->silencePattern(
152: '@^' . preg_quote($path, '@') . (is_dir($path) ? '/' : '$') . '@',
153: $levels
154: );
155: return $this;
156: }
157:
158: /**
159: * Silence errors in paths that match a regular expression
160: *
161: * @return $this
162: */
163: public function silencePattern(string $pattern, int $levels = \E_STRICT | \E_DEPRECATED | \E_USER_DEPRECATED)
164: {
165: $entry = [$pattern, $levels];
166: if (!in_array($entry, $this->Silenced, true)) {
167: $this->Silenced[] = $entry;
168: }
169: return $this;
170: }
171:
172: /**
173: * @internal
174: */
175: public function handleShutdown(): void
176: {
177: if (!$this->IsRegistered) {
178: return;
179: }
180:
181: $this->IsShuttingDown = true;
182:
183: $error = error_get_last();
184: if ($error && ($error['type'] & self::FATAL_ERRORS)) {
185: $this->IsShuttingDownOnFatalError = true;
186: $this->ExitStatus = 255;
187: $this->handleError($error['type'], $error['message'], $error['file'], $error['line']);
188: }
189: }
190:
191: /**
192: * @internal
193: */
194: public function handleError(int $level, string $message, string $file, int $line): bool
195: {
196: // Leave errors that would otherwise be silenced alone
197: if (!($level & error_reporting())) {
198: return false;
199: }
200:
201: // Ignore explicitly silenced errors
202: foreach ($this->Silenced as [$pattern, $levels]) {
203: if (($levels & $level)
204: && Regex::match($pattern, $file)) {
205: return true;
206: }
207: }
208:
209: // Convert the error to an exception
210: $exception = new ErrorException($message, 0, $level, $file, $line);
211:
212: if ($this->IsShuttingDown) {
213: $this->handleException($exception);
214: return true;
215: }
216:
217: throw $exception;
218: }
219:
220: /**
221: * @internal
222: */
223: public function handleException(Throwable $exception): void
224: {
225: if ($this->IsShuttingDown) {
226: Console::exception($exception);
227: return;
228: }
229:
230: $this->IsShuttingDown = true;
231: $this->IsShuttingDownOnUncaughtException = true;
232: if ($exception instanceof ExceptionInterface) {
233: $exitStatus = $exception->getExitStatus();
234: }
235: $this->ExitStatus = $exitStatus ??= self::DEFAULT_EXIT_STATUS;
236: Console::exception($exception);
237: exit($exitStatus);
238: }
239:
240: /**
241: * Report the exit status of the running script before it terminates on
242: * SIGTERM, SIGINT or SIGHUP
243: */
244: public function handleExitSignal(int $exitStatus): void
245: {
246: if (!$this->IsRegistered) {
247: throw new LogicException(sprintf('%s is not registered', static::class));
248: }
249:
250: if ($this->IsShuttingDown) {
251: return;
252: }
253:
254: $this->IsShuttingDown = true;
255: $this->IsShuttingDownOnExitSignal = true;
256: $this->ExitStatus = $exitStatus;
257: }
258: }
259: