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

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

146 lines 6.3 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 * Where this request's diagnostic journals live.
9 *
10 * Three channels need the same answer -- the checkpoint logger, the staged
11 * trace journal, and the request trace -- and each used to work it out for
12 * itself: ask abj404_getUploadsDir(), then filter through
13 * `abj404_ajax_trace_directory`. Both legs are WordPress filter dispatches,
14 * and apply_filters() fires the `all` hook BEFORE it looks at whether the
15 * named hook has any callbacks at all. So one journal record cost two `all`
16 * firings, and inside a render scope with an `all` observer installed each of
17 * those cost a full hook-registry inspection, which wrote lifecycle records of
18 * its own, which resolved the directory again.
19 *
20 * That loop is measurable and it belongs to the HOST, not to our data. On the
21 * owner's localhost (2,063 registered hooks / 4,024 callbacks across ten
22 * plugins) one part=all table AJAX request wrote 6,810 records and fired `all`
23 * 51,003 times with debug_mode on -- against 23,609 with it off -- while the
24 * bare-WP lab fixture with three times the row count measured 606 ms.
25 *
26 * Resolving once per request is also the more correct semantic, not just the
27 * cheaper one: a failing session's evidence belongs in ONE directory, and a
28 * path that moved halfway through a request would split the journal in two.
29 *
30 * WHEN the answer is fixed matters. The plugin records its first checkpoints
31 * during its own boot, before the plugins that filter `upload_dir` have
32 * registered, so an answer memoized that early would pin the journal to a
33 * directory the site does not actually use. The cache is therefore keyed by
34 * whether the filters have settled -- `wp_loaded` or `admin_init` has fired,
35 * both after every plugin has registered and both before any instrumented
36 * handler runs, since admin-ajax.php fires admin_init before dispatching the
37 * action. Boot records share one answer, post-boot records share another, and
38 * the switch costs exactly one extra resolution per request.
39 *
40 * Reading `did_action()` rather than registering an invalidation hook is
41 * deliberate, and the diagnostics hook-mutation contract enforces it: an
42 * add_action() here would be a diagnostics-owned registry mutation outside a
43 * lifecycle boundary, and bracketing it would make this leaf depend on the
44 * tracer that depends on this leaf to find out where to write. A read has no
45 * such cycle.
46 *
47 * A caller that passes a request-specific context is never cached at all,
48 * because the answer is then a function of data this class does not own.
49 */
50 final class ABJ_404_Solution_DiagnosticDirectoryResolver {
51
52 /**
53 * Points by which every plugin has had its chance to register a directory
54 * filter. Observed, never hooked: see the class comment.
55 */
56 const SETTLED_HOOKS = array('wp_loaded', 'admin_init');
57
58 /** @var array<string, string> Resolved directory, keyed by site and settledness. */
59 private static $resolved = array();
60
61 /**
62 * The directory this request's diagnostic journals live in, with a
63 * trailing separator, or '' when even the uploads directory is unknown.
64 *
65 * Never throws and never creates anything: callers that need the directory
66 * to exist create it themselves, because "resolved but unusable" and
67 * "unknown" are different findings and collapsing them is what made an
68 * empty journal unattributable.
69 *
70 * @param array<string, mixed> $context Request context for the
71 * `abj404_ajax_trace_directory` filter. A non-empty context is resolved
72 * live: it is request data, so memoizing it under a site-wide key would
73 * hand one caller's answer to another.
74 */
75 public static function resolve(array $context = array()): string {
76 if ($context !== array()) {
77 return self::resolveThroughFilters($context);
78 }
79 $key = self::siteKey() . '|' . (self::filtersHaveSettled() ? 'settled' : 'boot');
80 if (array_key_exists($key, self::$resolved)) {
81 return self::$resolved[$key];
82 }
83 $directory = self::resolveThroughFilters($context);
84 self::$resolved[$key] = $directory;
85 return $directory;
86 }
87
88 /**
89 * Discard the memoized answers. The seam the test suite uses to give a
90 * PHPUnit worker the end-of-request it never gets
91 * (ABJ404_RequestScopedStateReset).
92 */
93 public static function flush(): void {
94 self::$resolved = array();
95 }
96
97 /** Test seam, and the request-scoped reset the harness calls by name. */
98 public static function resetForTests(): void {
99 self::flush();
100 }
101
102 /**
103 * Have the plugins that filter the uploads directory had their chance to
104 * register? Answered by observation so this class registers nothing.
105 */
106 private static function filtersHaveSettled(): bool {
107 if (!function_exists('did_action')) {
108 return false;
109 }
110 foreach (self::SETTLED_HOOKS as $hook) {
111 if (did_action($hook) > 0) {
112 return true;
113 }
114 }
115 return false;
116 }
117
118 /**
119 * @param array<string, mixed> $context
120 */
121 private static function resolveThroughFilters(array $context): string {
122 $directory = function_exists('abj404_getUploadsDir') ? abj404_getUploadsDir() : '';
123 if (function_exists('apply_filters')) {
124 $directory = apply_filters('abj404_ajax_trace_directory', $directory, $context);
125 }
126 // A filter that returns an array or an object without __toString would
127 // otherwise warn or fatal on the cast. Anything that is not a scalar
128 // reads as "no directory", which every caller already handles.
129 $directory = is_scalar($directory) ? (string)$directory : '';
130 return $directory === '' ? '' : rtrim($directory, '/\\') . DIRECTORY_SEPARATOR;
131 }
132
133 /**
134 * The uploads directory is per-site on multisite, so a request that
135 * switch_to_blog()s must not be handed the previous site's journal path.
136 * Read from the global rather than through get_current_blog_id() because
137 * this runs during boot, in shutdown handlers, and inside the fatal path,
138 * where a function call into WordPress may not be available.
139 */
140 private static function siteKey(): string {
141 $blogId = $GLOBALS['blog_id'] ?? '';
142 return is_scalar($blogId) ? (string)$blogId : '';
143 }
144
145 }
146