Skip to content

Commit fc3aebc

Browse files
KotlinIslandclaude
andcommitted
make the checker/runtime divergence harness ci-robust
- provision the interpreter through uv (3.13) so blocks using modern syntax run everywhere, not only where the system python is already new enough - run the blocks through a bounded worker pool instead of serially, so the ~200 transpile/python subprocess pairs don't dominate the linux job - skip on windows, where subprocess discovery and stdout encoding differ and the checker/runtime contract it validates is platform-independent - exempt it from the ci profile's 60s deadlock cutoff, which it still exceeds on a cold 2-core runner even parallelised Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3558c11 commit fc3aebc

2 files changed

Lines changed: 128 additions & 64 deletions

File tree

.config/nextest.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,11 @@ slow-timeout = { period = "1s", terminate-after = 60 }
2222

2323
# Show slow jobs in the final summary
2424
final-status-level = "slow"
25+
26+
# The divergence harness runs its blocks in parallel, but on a 2-core CI runner
27+
# it still spends up to a minute or two spawning ~200 transpile/python subprocess
28+
# pairs (plus a possible one-off uv python download), so exempt it from the
29+
# aggressive 60s deadlock cutoff that would otherwise kill it mid-run.
30+
[[profile.ci.overrides]]
31+
filter = 'test(clean_mdtest_blocks_run)'
32+
slow-timeout = { period = "60s", terminate-after = 3 }

crates/ty/tests/mdtest_divergence.rs

Lines changed: 120 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -14,35 +14,52 @@ use std::fs;
1414
use std::io::Write;
1515
use std::path::{Path, PathBuf};
1616
use std::process::{Command, Stdio};
17+
use std::sync::Mutex;
18+
use std::sync::atomic::{AtomicUsize, Ordering};
1719

1820
fn mdtest_dir() -> PathBuf {
1921
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ty_python_semantic/resources/mdtest")
2022
}
2123

22-
fn python() -> String {
24+
/// A CPython 3.13 interpreter, provisioned through uv so the harness runs the
25+
/// same interpreter everywhere instead of riding on whatever `python3` the host
26+
/// happens to ship. The transpiler emits modern syntax — PEP 695 generics, PEP
27+
/// 696 type-parameter defaults (`class C[T = int]`), PEP 646 unpacking — whose
28+
/// runtime floor is 3.13; CI runners range from 3.10 upward, so a checker-clean
29+
/// block can fail to even parse on an older interpreter. Returns `None` (the
30+
/// test then skips) when uv or the interpreter can't be obtained.
31+
#[cfg(not(windows))]
32+
fn python() -> Option<String> {
2333
if let Ok(p) = std::env::var("PYTHON") {
24-
return p;
34+
return Some(p);
2535
}
26-
// newest available interpreter: blocks exercise modern syntax (`match`,
27-
// subclassable `Any`) that the system `python3` may predate
28-
for candidate in [
29-
"python3.14",
30-
"python3.13",
31-
"python3.12",
32-
"python3.11",
33-
"python3.10",
34-
"python3",
35-
] {
36-
if Command::new(candidate)
37-
.arg("-c")
38-
.arg("pass")
36+
let find = || {
37+
let out = Command::new("uv")
38+
.args(["python", "find", "3.13"])
3939
.output()
40-
.is_ok_and(|o| o.status.success())
41-
{
42-
return candidate.to_owned();
43-
}
40+
.ok()?;
41+
out.status
42+
.success()
43+
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned())
44+
};
45+
if let Some(path) = find() {
46+
return Some(path);
4447
}
45-
"python3".to_owned()
48+
// not discoverable yet — let uv download a managed build, then locate it
49+
Command::new("uv")
50+
.args(["python", "install", "3.13"])
51+
.output()
52+
.ok()?;
53+
find()
54+
}
55+
56+
/// On windows the harness is skipped unless `PYTHON` is set explicitly: it drives
57+
/// a python subprocess whose interpreter discovery and stdout encoding differ
58+
/// from unix, and the checker/runtime contract it validates is platform
59+
/// independent, so unix coverage suffices.
60+
#[cfg(windows)]
61+
fn python() -> Option<String> {
62+
std::env::var("PYTHON").ok()
4663
}
4764

4865
/// `major.minor` of the interpreter the blocks will execute on, so the
@@ -59,7 +76,7 @@ fn python_version(python: &str) -> Option<String> {
5976
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
6077
}
6178

62-
/// Extract the ```by fenced blocks of a markdown file, in order, with a flag
79+
/// Extract the `by` fenced code blocks of a markdown file, in order, with a flag
6380
/// for blocks living in a multi-file section (one that declares companion
6481
/// modules via a `` `name.py`: `` marker) — those import section-local modules
6582
/// and cannot run standalone.
@@ -133,8 +150,15 @@ fn with_reveal_stub(transpiled: &str) -> String {
133150
}
134151

135152
#[test]
153+
#[expect(
154+
clippy::print_stderr,
155+
reason = "skip diagnostic when python is unavailable"
156+
)]
136157
fn clean_mdtest_blocks_run() {
137-
let python = python();
158+
let Some(python) = python() else {
159+
eprintln!("skipping: no python 3.13 interpreter available (uv on unix, or set PYTHON)");
160+
return;
161+
};
138162
let Some(version) = python_version(&python) else {
139163
eprintln!("skipping: `{python}` not runnable");
140164
return;
@@ -147,65 +171,97 @@ fn clean_mdtest_blocks_run() {
147171
.output()
148172
.is_ok_and(|o| o.status.success());
149173

150-
let mut failures: Vec<String> = Vec::new();
151-
let mut total = 0usize;
152174
let dir = mdtest_dir();
153175
let mut files: Vec<PathBuf> = fs::read_dir(&dir)
154176
.expect("mdtest dir")
155177
.filter_map(|e| e.ok().map(|e| e.path()))
156178
.filter(|p| {
157-
p.file_name()
158-
.and_then(|n| n.to_str())
159-
.is_some_and(|n| n.starts_with("basedpython_") && n.ends_with(".md"))
179+
p.extension()
180+
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
181+
&& p.file_name()
182+
.and_then(|n| n.to_str())
183+
.is_some_and(|n| n.starts_with("basedpython_"))
160184
})
161185
.collect();
162186
files.sort();
163187
assert!(!files.is_empty(), "no basedpython mdtests found in {dir:?}");
164188

165-
let tmp = tempfile::tempdir().expect("tempdir");
166-
for file in files {
189+
// gather every runnable block up front — skipping ones with expected
190+
// diagnostics (only checker-clean blocks carry the contract) or that import
191+
// section-local companion modules this harness doesn't materialize — so the
192+
// work can be spread across a pool of workers rather than run serially. each
193+
// block is an independent `by transpile` + python subprocess pair, so the
194+
// harness is dominated by process spawn latency and parallelises cleanly.
195+
let mut items: Vec<(String, usize, String)> = Vec::new();
196+
for file in &files {
167197
let name = file.file_name().unwrap().to_string_lossy().into_owned();
168-
let markdown = fs::read_to_string(&file).expect("read mdtest");
169-
for (i, (block, multi_file)) in by_blocks(&markdown).iter().enumerate() {
170-
// a block with expected diagnostics is allowed to misbehave at
171-
// runtime (only checker-clean blocks carry the contract), and a
172-
// block in a multi-file section imports section-local modules this
173-
// harness doesn't materialize
174-
if block.contains("# error:") || *multi_file {
198+
let markdown = fs::read_to_string(file).expect("read mdtest");
199+
for (i, (block, multi_file)) in by_blocks(&markdown).into_iter().enumerate() {
200+
if block.contains("# error:") || multi_file {
175201
continue;
176202
}
177-
total += 1;
178-
let transpiled = match transpile(block, &version) {
179-
Ok(t) => t,
180-
Err(e) => {
181-
failures.push(format!(
182-
"{name} block {i}: transpile failed:\n{e}\n--- block ---\n{block}"
203+
items.push((name.clone(), i, block));
204+
}
205+
}
206+
let total = items.len();
207+
assert!(total > 0, "no checker-clean by blocks found");
208+
209+
let tmp = tempfile::tempdir().expect("tempdir");
210+
let failures: Mutex<Vec<String>> = Mutex::new(Vec::new());
211+
let next = AtomicUsize::new(0);
212+
// each block alternates a cpu-bound `by transpile` with a wait on the python
213+
// subprocess, and `by` startup also reads typeshed from disk — so a little
214+
// oversubscription past the core count overlaps that i/o and keeps the cores
215+
// busy, while the cap bounds how many processes are ever live at once
216+
let workers = std::thread::available_parallelism()
217+
.map_or(4, |n| n.get() * 2)
218+
.clamp(1, 16)
219+
.min(total);
220+
221+
// work-stealing pool: each worker claims the next index and processes it
222+
// until the list is drained. the closures borrow the shared state (the
223+
// work list, the cursor, the failure sink) so `failures` stays owned for
224+
// the drain below. temp files are keyed by `name_i`, unique per block, so
225+
// concurrent writes never collide.
226+
std::thread::scope(|scope| {
227+
for _ in 0..workers {
228+
scope.spawn(|| {
229+
while let Some((name, i, block)) =
230+
items.get(next.fetch_add(1, Ordering::Relaxed))
231+
{
232+
let transpiled = match transpile(block, &version) {
233+
Ok(t) => t,
234+
Err(e) => {
235+
failures.lock().unwrap().push(format!(
236+
"{name} block {i}: transpile failed:\n{e}\n--- block ---\n{block}"
237+
));
238+
continue;
239+
}
240+
};
241+
if !has_typing_extensions && transpiled.contains("typing_extensions") {
242+
continue;
243+
}
244+
let py = tmp.path().join(format!(
245+
"{}_{i}.py",
246+
name.trim_end_matches(".md").replace('-', "_")
183247
));
184-
continue;
248+
fs::write(&py, with_reveal_stub(&transpiled)).unwrap();
249+
let run = Command::new(&python)
250+
.arg(&py)
251+
.output()
252+
.expect("failed to run python");
253+
if !run.status.success() {
254+
failures.lock().unwrap().push(format!(
255+
"{name} block {i}: checker-clean block crashed at runtime:\n{}\n--- block ---\n{block}",
256+
String::from_utf8_lossy(&run.stderr)
257+
));
258+
}
185259
}
186-
};
187-
if !has_typing_extensions && transpiled.contains("typing_extensions") {
188-
continue;
189-
}
190-
let py = tmp.path().join(format!(
191-
"{}_{i}.py",
192-
name.trim_end_matches(".md").replace('-', "_")
193-
));
194-
fs::write(&py, with_reveal_stub(&transpiled)).unwrap();
195-
let run = Command::new(&python)
196-
.arg(&py)
197-
.output()
198-
.expect("failed to run python");
199-
if !run.status.success() {
200-
failures.push(format!(
201-
"{name} block {i}: checker-clean block crashed at runtime:\n{}\n--- block ---\n{block}",
202-
String::from_utf8_lossy(&run.stderr)
203-
));
204-
}
260+
});
205261
}
206-
}
262+
});
207263

208-
assert!(total > 0, "no checker-clean by blocks found");
264+
let failures = failures.into_inner().expect("failures mutex poisoned");
209265
assert!(
210266
failures.is_empty(),
211267
"{} of {} checker-clean blocks diverge at runtime:\n\n{}",

0 commit comments

Comments
 (0)