vector_core/
spawn_audit.rs1use std::path::Path;
15
16pub fn unbound_spawns(crate_root: &Path, src_root: &Path) -> Vec<String> {
19 let mut offenders = Vec::new();
20 let mut stack = vec![src_root.to_path_buf()];
21 while let Some(dir) = stack.pop() {
22 let Ok(entries) = std::fs::read_dir(&dir) else { continue };
23 for entry in entries.flatten() {
24 let path = entry.path();
25 if path.is_dir() {
26 stack.push(path);
27 continue;
28 }
29 if path.extension().and_then(|e| e.to_str()) != Some("rs") {
30 continue;
31 }
32 let rel = path
33 .strip_prefix(crate_root)
34 .unwrap_or(&path)
35 .to_string_lossy()
36 .replace('\\', "/");
37 let Ok(src) = std::fs::read_to_string(&path) else { continue };
38 for (line, n) in unbound_lines(&src) {
39 let _ = line;
40 offenders.push(format!("{rel}:{n}"));
41 }
42 }
43 }
44 offenders.sort();
45 offenders
46}
47
48pub fn has_unbound_spawn(src: &str) -> bool {
50 unbound_lines(src).next().is_some()
51}
52
53const NEEDLE: &str = concat!("tokio::", "spawn(");
61const RT_NEEDLE: &str = concat!("rt::", "spawn(");
62
63fn unbound_lines(src: &str) -> impl Iterator<Item = (&str, usize)> {
64 let all: Vec<&str> = src.lines().collect();
65 let cut = all
66 .windows(2)
67 .position(|w| {
68 w[0].trim() == "#[cfg(test)]"
69 && matches!(w[1].trim_start(), l if l.starts_with("mod ") || l.starts_with("pub mod ") || l.starts_with("pub(crate) mod "))
70 })
71 .unwrap_or(all.len());
72 let lines: Vec<&str> = all[..cut].to_vec();
73 let owned: Vec<(&str, usize)> = lines
74 .iter()
75 .enumerate()
76 .filter(|(i, line)| {
77 (line.contains(NEEDLE) || line.contains(RT_NEEDLE))
78 && !line.trim_start().starts_with("//")
79 && !line.contains("spawn-detached:")
80 && !lines[..*i].iter().rev().take(4).any(|p| p.contains("spawn-detached:"))
81 })
82 .map(|(i, line)| (*line, i + 1))
83 .collect();
84 owned.into_iter()
85}
86
87pub fn account_access_off_task(crate_root: &Path, src_root: &Path) -> Vec<String> {
95 let mut offenders = Vec::new();
96 let mut stack = vec![src_root.to_path_buf()];
97 while let Some(dir) = stack.pop() {
98 let Ok(entries) = std::fs::read_dir(&dir) else { continue };
99 for entry in entries.flatten() {
100 let path = entry.path();
101 if path.is_dir() {
102 stack.push(path);
103 continue;
104 }
105 if path.extension().and_then(|e| e.to_str()) != Some("rs") {
106 continue;
107 }
108 let rel = path.strip_prefix(crate_root).unwrap_or(&path).to_string_lossy().replace('\\', "/");
109 let Ok(src) = std::fs::read_to_string(&path) else { continue };
110 let prod = src.split("#[cfg(test)]").next().unwrap_or("");
111 let lines: Vec<&str> = prod.lines().collect();
112 for (i, line) in lines.iter().enumerate() {
113 if !line.contains("spawn_blocking") && !line.contains("std::thread::spawn") {
114 continue;
115 }
116 if line.trim_start().starts_with("//") {
117 continue;
118 }
119 let window = lines[i..(i + 14).min(lines.len())].join("\n");
120 let body = spawned_call(&window);
121 if body.contains("db::") || body.contains("STATE.") {
122 offenders.push(format!("{rel}:{}", i + 1));
123 }
124 }
125 }
126 }
127 offenders.sort();
128 offenders
129}
130
131fn spawned_call(text: &str) -> &str {
134 let from = text.find("spawn_blocking").or_else(|| text.find("std::thread::spawn")).unwrap_or(0);
135 let mut depth = 0i32;
136 for (at, c) in text[from..].char_indices() {
137 match c {
138 '(' => depth += 1,
139 ')' => {
140 depth -= 1;
141 if depth == 0 {
142 return &text[from..from + at + 1];
143 }
144 }
145 _ => {}
146 }
147 }
148 &text[from..]
149}
150
151pub fn assert_all_spawns_bound(crate_root: &Path, pending: &[&str]) {
155 let src_root = crate_root.join("src");
156 let offenders: Vec<String> = unbound_spawns(crate_root, &src_root)
157 .into_iter()
158 .filter(|o| !pending.iter().any(|p| o.starts_with(&format!("{p}:"))))
159 .collect();
160 assert!(
161 offenders.is_empty(),
162 "these tasks are not bound to an account — use vector_core::db::spawn_bound so their \
163 work follows the account they started under, or mark the site \
164 `// spawn-detached: <why it owns no account state>`:\n {}",
165 offenders.join("\n ")
166 );
167
168 let converted: Vec<&&str> = pending
169 .iter()
170 .filter(|file| {
171 std::fs::read_to_string(crate_root.join(file))
172 .map(|src| !has_unbound_spawn(&src))
173 .unwrap_or(false)
174 })
175 .collect();
176 assert!(
177 converted.is_empty(),
178 "these files are fully converted — delete them from the pending list so they stay \
179 converted:\n {converted:?}"
180 );
181
182 let off_task = account_access_off_task(crate_root, &src_root);
183 assert!(
184 off_task.is_empty(),
185 "a blocking thread runs outside the task, so it does not carry the caller's account — \
186 these would read or write whoever is live instead. Do the work inline, or capture the \
187 session and re-enter it with db::with_session:\n {}",
188 off_task.join("\n ")
189 );
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn a_bare_spawn_is_caught_and_a_marked_one_is_not() {
198 assert!(has_unbound_spawn("fn f() { tokio::spawn(async {}); }"));
199 assert!(!has_unbound_spawn(
200 "fn f() { tokio::spawn(async {}); } // spawn-detached: pure CPU."
201 ));
202 assert!(!has_unbound_spawn(
203 "// spawn-detached: pure CPU.\ntokio::spawn(async {});"
204 ));
205 assert!(!has_unbound_spawn("fn f() { db::spawn_bound(async {}); }"));
206 }
207
208 #[test]
209 fn the_marker_does_not_reach_past_its_own_site() {
210 let far = format!("// spawn-detached: nope.\n{}tokio::spawn(async {{}});", "\n".repeat(5));
213 assert!(has_unbound_spawn(&far));
214 }
215
216 #[test]
217 fn test_code_spawns_freely() {
218 assert!(!has_unbound_spawn("#[cfg(test)]\nmod t { fn f() { tokio::spawn(async {}); } }"));
219 }
220
221 #[test]
222 fn only_the_blocking_closure_counts_not_what_follows_it() {
223 let inside = "let x = spawn_blocking(move || {\n db::get(1)\n}).await;";
224 let after = "let x = spawn_blocking(move || work(a, b)).await;\ndb::spawn_bound(async {});";
225 assert!(spawned_call(inside).contains("db::"));
226 assert!(!spawned_call(after).contains("db::"));
227 }
228}