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
53fn unbound_lines(src: &str) -> impl Iterator<Item = (&str, usize)> {
58 let prod = src.split("#[cfg(test)]").next().unwrap_or("");
59 let lines: Vec<&str> = prod.lines().collect();
60 let owned: Vec<(&str, usize)> = lines
61 .iter()
62 .enumerate()
63 .filter(|(i, line)| {
64 line.contains("tokio::spawn(")
65 && !line.trim_start().starts_with("//")
66 && !line.contains("spawn-detached:")
67 && !lines[..*i].iter().rev().take(4).any(|p| p.contains("spawn-detached:"))
68 })
69 .map(|(i, line)| (*line, i + 1))
70 .collect();
71 owned.into_iter()
72}
73
74pub fn account_access_off_task(crate_root: &Path, src_root: &Path) -> Vec<String> {
82 let mut offenders = Vec::new();
83 let mut stack = vec![src_root.to_path_buf()];
84 while let Some(dir) = stack.pop() {
85 let Ok(entries) = std::fs::read_dir(&dir) else { continue };
86 for entry in entries.flatten() {
87 let path = entry.path();
88 if path.is_dir() {
89 stack.push(path);
90 continue;
91 }
92 if path.extension().and_then(|e| e.to_str()) != Some("rs") {
93 continue;
94 }
95 let rel = path.strip_prefix(crate_root).unwrap_or(&path).to_string_lossy().replace('\\', "/");
96 let Ok(src) = std::fs::read_to_string(&path) else { continue };
97 let prod = src.split("#[cfg(test)]").next().unwrap_or("");
98 let lines: Vec<&str> = prod.lines().collect();
99 for (i, line) in lines.iter().enumerate() {
100 if !line.contains("spawn_blocking") && !line.contains("std::thread::spawn") {
101 continue;
102 }
103 if line.trim_start().starts_with("//") {
104 continue;
105 }
106 let body = lines[i..(i + 14).min(lines.len())].join("\n");
107 if body.contains("db::") || body.contains("STATE.") {
108 offenders.push(format!("{rel}:{}", i + 1));
109 }
110 }
111 }
112 }
113 offenders.sort();
114 offenders
115}
116
117pub fn assert_all_spawns_bound(crate_root: &Path, pending: &[&str]) {
121 let src_root = crate_root.join("src");
122 let offenders: Vec<String> = unbound_spawns(crate_root, &src_root)
123 .into_iter()
124 .filter(|o| !pending.iter().any(|p| o.starts_with(&format!("{p}:"))))
125 .collect();
126 assert!(
127 offenders.is_empty(),
128 "these tasks are not bound to an account — use vector_core::db::spawn_bound so their \
129 work follows the account they started under, or mark the site \
130 `// spawn-detached: <why it owns no account state>`:\n {}",
131 offenders.join("\n ")
132 );
133
134 let converted: Vec<&&str> = pending
135 .iter()
136 .filter(|file| {
137 std::fs::read_to_string(crate_root.join(file))
138 .map(|src| !has_unbound_spawn(&src))
139 .unwrap_or(false)
140 })
141 .collect();
142 assert!(
143 converted.is_empty(),
144 "these files are fully converted — delete them from the pending list so they stay \
145 converted:\n {converted:?}"
146 );
147
148 let off_task = account_access_off_task(crate_root, &src_root);
149 assert!(
150 off_task.is_empty(),
151 "a blocking thread runs outside the task, so it does not carry the caller's account — \
152 these would read or write whoever is live instead. Do the work inline, or capture the \
153 session and re-enter it with db::with_session:\n {}",
154 off_task.join("\n ")
155 );
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn a_bare_spawn_is_caught_and_a_marked_one_is_not() {
164 assert!(has_unbound_spawn("fn f() { tokio::spawn(async {}); }"));
165 assert!(!has_unbound_spawn(
166 "fn f() { tokio::spawn(async {}); } // spawn-detached: pure CPU."
167 ));
168 assert!(!has_unbound_spawn(
169 "// spawn-detached: pure CPU.\ntokio::spawn(async {});"
170 ));
171 assert!(!has_unbound_spawn("fn f() { db::spawn_bound(async {}); }"));
172 }
173
174 #[test]
175 fn the_marker_does_not_reach_past_its_own_site() {
176 let far = format!("// spawn-detached: nope.\n{}tokio::spawn(async {{}});", "\n".repeat(5));
179 assert!(has_unbound_spawn(&far));
180 }
181
182 #[test]
183 fn test_code_spawns_freely() {
184 assert!(!has_unbound_spawn("#[cfg(test)]\nmod t { fn f() { tokio::spawn(async {}); } }"));
185 }
186}