Skip to main content

php_lsp/backend/
debug_gate.rs

1//! Test-only hold points for blocking-pool work.
2//!
3//! The responsiveness regression tests need to prove a notification handler's
4//! synchronous work runs off the connection's serve future. Requests can pin
5//! that with a pure ordering check (see `assert_stays_responsive` in the test
6//! harness), but a notification has no response to order against — earlier
7//! versions fell back to wall-clock budgets, which flaked on loaded CI
8//! runners. A gate makes the check deterministic instead: the test arms a
9//! named section, the handler's blocking closure parks in `pass` until
10//! released, and the test asserts the server still answers a probe while the
11//! work is provably in flight. On a correct build that holds under any
12//! scheduling; on a regressed (inline) build the serve future itself parks,
13//! no later message is processed, and the probe times out — a hard failure,
14//! never a flake.
15//!
16//! Production builds compile the whole mechanism out: without the
17//! `test-hooks` feature (enabled only for test targets, via the self
18//! dev-dependency in Cargo.toml) `DebugGate` is a zero-sized no-op and the
19//! hold/release custom methods are never registered, so a real client cannot
20//! arm anything.
21
22/// Gate section around `did_open`'s parse-diagnostics closure.
23pub const GATE_DID_OPEN_PARSE: &str = "didOpen.parse";
24/// Gate section around `did_save`'s diagnostics-recompute closure.
25pub const GATE_DID_SAVE_DIAGNOSTICS: &str = "didSave.diagnostics";
26/// Gate section around `did_change_watched_files`' batched parse closure.
27pub const GATE_DID_CHANGE_WATCHED_FILES: &str = "didChangeWatchedFiles.parse";
28/// Gate section around `will_rename_files`' `use`-edit batch closure.
29pub const GATE_WILL_RENAME_FILES: &str = "willRenameFiles.useEdits";
30/// Gate section around `will_delete_files`' `use`-edit batch closure.
31pub const GATE_WILL_DELETE_FILES: &str = "willDeleteFiles.useEdits";
32/// Gate section around `prepare_call_hierarchy`'s indexed-lookup closure.
33pub const GATE_PREPARE_CALL_HIERARCHY: &str = "prepareCallHierarchy.lookup";
34/// Gate section around `selection_range`'s AST-walk closure.
35pub const GATE_SELECTION_RANGE: &str = "selectionRange.walk";
36/// Gate section around `goto_implementation`'s method-decl cursor check.
37pub const GATE_GOTO_IMPLEMENTATION: &str = "gotoImplementation.methodDeclCheck";
38/// Gate section around `goto_definition`'s same-document/index fallback work.
39pub const GATE_GOTO_DEFINITION: &str = "gotoDefinition.lookup";
40/// Gate section around `rename`'s property-decl cursor check + variable rename.
41pub const GATE_RENAME_VARIABLE: &str = "rename.variable";
42/// Gate section around `references`' same-document variable walk.
43pub const GATE_REFERENCES_VARIABLE: &str = "references.variable";
44/// Gate section around `completion_resolve`'s all-indexes signature/doc lookup.
45pub const GATE_COMPLETION_RESOLVE: &str = "completionResolve.lookup";
46/// Gate section around `inlay_hint_resolve`'s all-indexes doc lookup.
47pub const GATE_INLAY_HINT_RESOLVE: &str = "inlayHintResolve.lookup";
48/// Gate section around `on_type_formatting`'s whole-document scan.
49pub const GATE_ON_TYPE_FORMATTING: &str = "onTypeFormatting.scan";
50/// Gate section around `prepare_rename`'s keyword member-access check.
51pub const GATE_PREPARE_RENAME: &str = "prepareRename.walk";
52/// Gate section around `document_symbol`'s AST walk.
53pub const GATE_DOCUMENT_SYMBOL: &str = "documentSymbol.walk";
54/// Gate section around `folding_range`'s AST walk plus comment/region scan.
55pub const GATE_FOLDING_RANGE: &str = "foldingRange.walk";
56/// Gate section around `document_link`'s AST walk plus `@link`/`@see` scan.
57pub const GATE_DOCUMENT_LINK: &str = "documentLink.walk";
58/// Gate section around `semantic_tokens_full`'s AST walk.
59pub const GATE_SEMANTIC_TOKENS_FULL: &str = "semanticTokensFull.walk";
60/// Gate section around `semantic_tokens_range`'s AST walk.
61pub const GATE_SEMANTIC_TOKENS_RANGE: &str = "semanticTokensRange.walk";
62/// Gate section around `semantic_tokens_full_delta`'s AST walk.
63pub const GATE_SEMANTIC_TOKENS_FULL_DELTA: &str = "semanticTokensFullDelta.walk";
64/// Gate section around `linked_editing_range`'s highlight/class-scoping walk.
65pub const GATE_LINKED_EDITING_RANGE: &str = "linkedEditingRange.walk";
66/// Gate section around `goto_type_definition`'s exact/short-name fallback chain.
67pub const GATE_GOTO_TYPE_DEFINITION: &str = "gotoTypeDefinition.walk";
68/// Gate section around type-hierarchy lookup/result construction.
69pub const GATE_TYPE_HIERARCHY: &str = "typeHierarchy.lookup";
70/// Gate section around `moniker`'s AST member lookup.
71pub const GATE_MONIKER: &str = "moniker.lookup";
72/// Gate section around `inline_value`'s range text scan.
73pub const GATE_INLINE_VALUE: &str = "inlineValue.scan";
74/// Gate section around `codeAction/resolve`'s deferred action generation.
75pub const GATE_CODE_ACTION_RESOLVE: &str = "codeActionResolve.generate";
76/// Gate section around `workspace_decl_locations`'s post-empty-attempt
77/// settle-wait, held right after the first workspace-index read comes back
78/// empty and before the wait-then-retry — lets a test force a companion
79/// declaring file to open while the read that missed it is still in flight.
80pub const GATE_WORKSPACE_DECL_LOCATIONS_RETRY: &str = "workspaceDeclLocations.retry";
81/// Gate section around the usage-symbol settle-wait in `handle_references`/
82/// `indexed_rename`, held right after mir's own per-file resolution first
83/// comes back empty (companion file not yet known) and before the
84/// wait-then-retry — lets a test force a companion declaring file to open
85/// while mir's resolution attempt is still in flight, so the retry can
86/// observe mir resolving it correctly instead of falling through to the
87/// AST-heuristic FQN fallback.
88pub const GATE_USAGE_SYMBOL_RETRY: &str = "usageSymbol.retry";
89
90#[cfg(not(feature = "test-hooks"))]
91#[derive(Default)]
92pub struct DebugGate;
93
94#[cfg(not(feature = "test-hooks"))]
95impl DebugGate {
96    #[inline(always)]
97    pub fn new() -> Self {
98        DebugGate
99    }
100
101    #[inline(always)]
102    pub fn pass(&self, _section: &str) {}
103
104    #[inline(always)]
105    pub fn held_section(&self) -> Option<String> {
106        None
107    }
108}
109
110#[cfg(feature = "test-hooks")]
111pub use real::DebugGate;
112
113#[cfg(feature = "test-hooks")]
114mod real {
115    use std::sync::{Condvar, Mutex};
116
117    #[derive(Default)]
118    struct GateState {
119        /// Section name the next matching `pass` call should hold at.
120        armed: Option<String>,
121        /// Section currently parked at the gate (surfaced via `debugStats`).
122        held: Option<String>,
123        released: bool,
124    }
125
126    #[derive(Default)]
127    pub struct DebugGate {
128        state: Mutex<GateState>,
129        cv: Condvar,
130    }
131
132    impl DebugGate {
133        pub fn new() -> Self {
134            Self::default()
135        }
136
137        /// Arm the gate: the next `pass(section)` parks until [`release`](Self::release).
138        /// One passage per arm; re-arming replaces any previous armed section.
139        pub fn arm(&self, section: &str) {
140            let mut s = self.state.lock().unwrap();
141            s.armed = Some(section.to_owned());
142            s.released = false;
143        }
144
145        pub fn release(&self) {
146            let mut s = self.state.lock().unwrap();
147            s.armed = None;
148            s.released = true;
149            self.cv.notify_all();
150        }
151
152        /// Section currently parked at the gate, if any.
153        pub fn held_section(&self) -> Option<String> {
154            self.state.lock().unwrap().held.clone()
155        }
156
157        /// Hold point. No-op unless the gate is armed for exactly `section`;
158        /// when it is, claims the arm (so concurrent passages can't stack) and
159        /// parks until released. The park is capped at 60s: a test that fails
160        /// without releasing must not leave this task parked forever — tokio's
161        /// runtime shutdown joins in-flight blocking tasks, so an uncapped park
162        /// would turn that test's failure report into an eternal hang.
163        pub fn pass(&self, section: &str) {
164            let s = self.state.lock().unwrap();
165            if s.armed.as_deref() != Some(section) {
166                return;
167            }
168            let mut s = s;
169            s.armed = None;
170            s.held = Some(section.to_owned());
171            let (mut s, _timed_out) = self
172                .cv
173                .wait_timeout_while(s, std::time::Duration::from_secs(60), |s| !s.released)
174                .unwrap();
175            s.held = None;
176            s.released = false;
177        }
178    }
179}
180
181#[cfg(all(test, feature = "test-hooks"))]
182mod tests {
183    use super::*;
184    use std::sync::Arc;
185
186    #[test]
187    fn unarmed_pass_is_a_no_op() {
188        let gate = DebugGate::default();
189        gate.pass("anything");
190        assert_eq!(gate.held_section(), None);
191    }
192
193    #[test]
194    fn armed_pass_parks_until_release() {
195        let gate = Arc::new(DebugGate::default());
196        gate.arm("sec");
197        let g = Arc::clone(&gate);
198        let t = std::thread::spawn(move || g.pass("sec"));
199        while gate.held_section().as_deref() != Some("sec") {
200            std::thread::yield_now();
201        }
202        assert!(!t.is_finished());
203        gate.release();
204        t.join().unwrap();
205        assert_eq!(gate.held_section(), None);
206    }
207
208    #[test]
209    fn arm_holds_only_the_named_section_and_only_once() {
210        let gate = Arc::new(DebugGate::default());
211        gate.arm("sec");
212        gate.pass("other"); // wrong section: must not park
213        let g = Arc::clone(&gate);
214        let t = std::thread::spawn(move || g.pass("sec"));
215        while gate.held_section().is_none() {
216            std::thread::yield_now();
217        }
218        gate.release();
219        t.join().unwrap();
220        gate.pass("sec"); // arm consumed: must not park again
221    }
222}