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/// first `#[cfg(test)]` onward is ignored.
57fn 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
74/// Account access from a thread that does not carry the account.
75///
76/// A tokio task-local rides the task, not the thread. `spawn_blocking` and
77/// `std::thread::spawn` run outside it, so `db::` and `STATE` there resolve
78/// whoever is live rather than the caller's account — the one way left to write
79/// one account's data into another's storage. Nothing in the tree does this;
80/// this is what keeps it that way.
81pub 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
117/// The assertion both crates run. `pending` is a shrink-only worklist of files
118/// not yet converted; it is a ratchet, not an exemption, so a file that no
119/// longer spawns unbound must be deleted from it.
120pub 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        // Four lines of slack, so a marker above a multi-line setup still
177        // applies — but not so far that it silently covers the NEXT spawn.
178        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}