Skip to main content

vector_core/
spawn_audit.rs

1//! The policy check behind [`crate::db::spawn_bound`], shared by every crate
2//! that spawns per-account work.
3//!
4//! A bare `tokio::spawn` leaves its task resolving whoever is logged in when it
5//! finally asks, which is how account A's work lands in account B's storage.
6//! That mistake is invisible in review, so it is caught mechanically instead:
7//! walk a source tree and fail on an unbound spawn.
8//!
9//! A task that genuinely owns no account state — a process-lifetime listener, a
10//! socket drain, CPU work on bytes already in hand — is exempted per site with a
11//! `// spawn-detached: <why>` marker on the line or just above it. Per site, not
12//! per file: files hold both kinds, and exempting one wholesale hides the other.
13
14use std::path::Path;
15
16/// Every unbound `tokio::spawn` under `src_root`, as `path:line` relative to
17/// `crate_root`. Empty means the tree is clean.
18pub 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
48/// Whether one file's shipping code still spawns unbound. Used by the ratchet.
49pub fn has_unbound_spawn(src: &str) -> bool {
50    unbound_lines(src).next().is_some()
51}
52
53/// The unbound spawn lines in one file's shipping code, as `(line, 1-based no)`.
54///
55/// Tests spawn freely — only shipping code is bound — so everything from the
56/// test MODULE onward is ignored. The cut is the first `#[cfg(test)]` that
57/// introduces a `mod`, not the first `#[cfg(test)]` anywhere: a test-only hook
58/// function early in a file would otherwise hide every line after it.
59// Assembled at compile time so this file never contains the text it searches for.
60const 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
87/// Account access from a thread that does not carry the account.
88///
89/// A tokio task-local rides the task, not the thread. `spawn_blocking` and
90/// `std::thread::spawn` run outside it, so `db::` and `STATE` there resolve
91/// whoever is live rather than the caller's account — the one way left to write
92/// one account's data into another's storage. Nothing in the tree does this;
93/// this is what keeps it that way.
94pub 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
131/// The spawn call itself, from its name to its closing paren: what follows it runs back
132/// on the task.
133fn 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
151/// The assertion both crates run. `pending` is a shrink-only worklist of files
152/// not yet converted; it is a ratchet, not an exemption, so a file that no
153/// longer spawns unbound must be deleted from it.
154pub 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        // Four lines of slack, so a marker above a multi-line setup still
211        // applies — but not so far that it silently covers the NEXT spawn.
212        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}