Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 20 additions & 33 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ serial = { max-threads = 1 }
filter = 'binary(file_watching)'
test-group = 'serial'

# a divergence harness shard works on `SHARD_WORKERS` blocks at once, each a `by` and
# a python process (`crates/ty/tests/mdtest_divergence.rs`). nextest counts them against
# the machine's cores, so shards running beside one another and beside other tests do
# not oversubscribe it
[[profile.default.overrides]]
filter = 'test(clean_mdtest_blocks_run)'
threads-required = 2

# the transpiler's runtime tests transpile for the interpreter they run on. a few
# programs need a newer python than the one found, or more of it —
# `typing_extensions`, the `regex` package — which a machine often does not have.
Expand All @@ -32,40 +40,19 @@ slow-timeout = { period = "1s", terminate-after = 60 }
# Show slow jobs in the final summary
final-status-level = "slow"

# The divergence harness runs its blocks in parallel, but on a 2-core CI runner
# it still spends up to a minute or two spawning ~200 transpile/python subprocess
# pairs (plus a possible one-off uv python download), so exempt it from the
# aggressive 60s deadlock cutoff that would otherwise kill it mid-run.
#
# The budget grows with every basedpython feature that adds mdtest blocks — it was
# already at 152s of the previous 180s ceiling — so the ceiling is deliberately
# well clear of the current cost rather than just above it. `type def` blocks are
# the most expensive kind, because transpiling one evaluates its type functions,
# and each evaluation spawns an interpreter.
#
# It had drifted back to "just above it": 279.6s on main and 284.0s after the
# 2026-08-13 upstream merge, against a 360s ceiling. Runner speed varies by more
# than the 21% of headroom that left, and the same commit timed out at 360s on two
# runs out of three while passing at 284s on the third. 10 minutes puts the cost
# back under half the ceiling and still catches a deadlock.
#
# And it drifted back again, the same way. On windows the harness measured 561.1s
# on main against the 600s ceiling — 6.5% of headroom — and basedpython-ui's
# mdtests, which took the runnable corpus from 1086 blocks to 1137 (+4.7%), were
# enough to exceed it. The cost is a transpile/python subprocess pair per block,
# and windows spawns processes far more slowly than linux, so windows is where the
# ceiling binds first. 20 minutes puts the cost back under half, as before.
# The divergence harness transpiles and runs every checker-clean `by` block in the
# mdtests, and checks each with its unused imports removed. A block costs a
# `by transpile`, a `by check --fix` and a python run, and the transpile and the
# check each build a database and infer what the block needs of the builtins from
# scratch: that, not starting the processes, is the cost, and it grows with every
# basedpython feature that adds mdtest blocks.
#
# The harness already parallelises internally over a work-stealing pool, so this
# is not a serialisation problem: it is that the corpus only ever grows while a CI
# runner has two cores. Cutting the per-block cost is the lever, and the harness
# now takes the cheapest one — a block naming a module the interpreter does not
# have is dropped before it is transpiled rather than after, which measured 46.3s
# to 40.4s (12.7%) locally. That more than pays for the blocks added here, but the
# ceiling is raised anyway: 561.1s of 600s was already too close before any of
# them. The next lever, if this drifts a fourth time, is calling the transpiler
# in-process instead of spawning `by` per block, which would remove half the
# process spawns outright.
# The corpus is spread over eight shards by `shard_of` in
# `crates/ty/tests/mdtest_divergence.rs`, each a test of its own, so the limit holds
# one shard rather than the whole corpus. Even so a shard is minutes of work on a
# two-core runner, and windows spawns processes far more slowly than linux, so the
# shards are exempt from the 60s deadlock cutoff. If a shard approaches this limit,
# the lever is more shards, not a higher limit.
[[profile.ci.overrides]]
filter = 'test(clean_mdtest_blocks_run)'
slow-timeout = { period = "60s", terminate-after = 20 }
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -769,7 +769,10 @@ jobs:
version: "0.13.5"
checksum: "a38b8d1902b7cfbc76b423ba1bb456f623f42064cdc72628b6075b89afd1019a"
enable-cache: "true"
cache-python: true
# basedpython: the install below gives the interpreter a minor version link, a
# junction on windows, which the cache does not restore as one: every run after
# the one that saved it failed to replace it (`os error 267`)
cache-python: false
- name: "Install uv (macOS)"
if: runner.os == 'macOS'
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 61 additions & 2 deletions crates/by_transforms/src/transforms/ast_driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3239,9 +3239,68 @@ mod subsumed_lowerings {
check(&type_test_target_cases(), PythonVersion::PY312);
}

/// the passes [`type_expression_cases`] has cases for. each pass's cases are a test of their
/// own, which runs beside the others rather than after them
const TYPE_EXPRESSION_PASSES: &[&str] = &[
"TupleLiteralTypePass",
"CallableSyntaxPass",
"AnonNamedTuplePass",
"ProtocolTypePass",
"TypedDictLiteralPass",
"CheckedCastPass",
];

fn check_type_expressions_of(pass: &str) {
let cases: Vec<Case> = type_expression_cases()
.into_iter()
.filter(|case| case.pass == pass)
.collect();
assert!(!cases.is_empty(), "no type expression case is for `{pass}`");
check(&cases, PythonVersion::PY310);
}

#[test]
fn a_type_expression_a_pass_moves_is_written_lowered() {
check(&type_expression_cases(), PythonVersion::PY310);
fn a_type_expression_a_tuple_literal_type_moves_is_written_lowered() {
check_type_expressions_of("TupleLiteralTypePass");
}

#[test]
fn a_type_expression_a_callable_moves_is_written_lowered() {
check_type_expressions_of("CallableSyntaxPass");
}

#[test]
fn a_type_expression_an_anonymous_named_tuple_moves_is_written_lowered() {
check_type_expressions_of("AnonNamedTuplePass");
}

#[test]
fn a_type_expression_a_protocol_moves_is_written_lowered() {
check_type_expressions_of("ProtocolTypePass");
}

#[test]
fn a_type_expression_a_typed_dict_literal_moves_is_written_lowered() {
check_type_expressions_of("TypedDictLiteralPass");
}

#[test]
fn a_type_expression_a_checked_cast_moves_is_written_lowered() {
check_type_expressions_of("CheckedCastPass");
}

/// a case for a pass with no test of its own would never run
#[test]
fn every_type_expression_pass_has_a_test() {
let mut passes: Vec<&str> = type_expression_cases()
.iter()
.map(|case| case.pass)
.collect();
passes.sort_unstable();
passes.dedup();
let mut tested = TYPE_EXPRESSION_PASSES.to_vec();
tested.sort_unstable();
assert_eq!(passes, tested);
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions crates/ty/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ insta = { workspace = true, features = ["filters", "json"] }
insta-cmd = { workspace = true }
regex = { workspace = true }
tempfile = { workspace = true }
test-case = { workspace = true }
toml = { workspace = true }
zip = { workspace = true }

Expand Down
62 changes: 47 additions & 15 deletions crates/ty/tests/mdtest_divergence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};

use test_case::test_case;

fn mdtest_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ty_python_semantic/resources/mdtest")
}
Expand Down Expand Up @@ -449,12 +451,46 @@ fn with_reveal_stub(transpiled: &str) -> String {
}
}

#[test]
/// how many tests the blocks are spread over. each is a test of its own, so nextest runs them
/// beside one another and holds each to its own time limit, where one test running the whole
/// corpus was held to a limit that the corpus outgrows as features add blocks
const SHARDS: u64 = 8;

/// the shard the blocks of the mdtest at `name` run in. a path's FNV-1a hash keeps a file in
/// one shard as others come and go, and spreads the files without a list to rebalance. an
/// entry of [`EXCLUDED`], [`REQUIRES`] or [`DIVERGES_BELOW`] goes to its file's shard the same
/// way, so the shard that runs a section is the one that checks the entries naming it
fn shard_of(name: &str) -> u64 {
name.bytes().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
(hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
}) % SHARDS
}

/// the blocks a shard works on at once. each alternates a cpu-bound `by` with a wait on the
/// python it runs, so two keep a core busy. nextest runs shards beside one another and beside
/// other tests, so a shard declares these threads to it, as `threads-required` in
/// `.config/nextest.toml`, rather than sizing itself to the whole machine
const SHARD_WORKERS: usize = 2;

#[test_case(0)]
#[test_case(1)]
#[test_case(2)]
#[test_case(3)]
#[test_case(4)]
#[test_case(5)]
#[test_case(6)]
#[test_case(7)]
fn clean_mdtest_blocks_run(shard: u64) {
run_shard(shard);
}

/// the checker-clean blocks of the mdtests in `shard`, each transpiled and run
#[expect(
clippy::print_stderr,
reason = "skip diagnostic when python is unavailable"
)]
fn clean_mdtest_blocks_run() {
fn run_shard(shard: u64) {
assert!(shard < SHARDS, "shard {shard} is past the last of {SHARDS}");
let Some(python) = python() else {
eprintln!("skipping: no python 3.13 interpreter available (uv on unix, or set PYTHON)");
return;
Expand Down Expand Up @@ -581,6 +617,9 @@ fn clean_mdtest_blocks_run() {
.expect("an mdtest is under the mdtest directory")
.to_string_lossy()
.replace('\\', "/");
if shard_of(&name) != shard {
continue;
}
let markdown = fs::read_to_string(file).expect("read mdtest");
for (i, block) in by_blocks(&markdown).into_iter().enumerate() {
if has_expected_diagnostics(&block.source) || block.multi_file {
Expand Down Expand Up @@ -624,13 +663,13 @@ fn clean_mdtest_blocks_run() {
let stale: Vec<String> = EXCLUDED
.iter()
.zip(&excluded_seen)
.filter(|(_, seen)| !**seen)
.filter(|((file, ..), seen)| shard_of(file) == shard && !**seen)
.map(|((file, heading, _), _)| format!("{file}: {heading}"))
.chain(
REQUIRES
.iter()
.zip(&requires_seen)
.filter(|(_, seen)| !**seen)
.filter(|((file, ..), seen)| shard_of(file) == shard && !**seen)
.map(|((file, heading, _, _), _)| {
format!("{file}: {}", heading.unwrap_or("every section"))
}),
Expand All @@ -639,7 +678,7 @@ fn clean_mdtest_blocks_run() {
DIVERGES_BELOW
.iter()
.zip(&diverges_below_seen)
.filter(|(_, seen)| !**seen)
.filter(|((file, ..), seen)| shard_of(file) == shard && !**seen)
.map(|((file, heading, _, _), _)| format!("{file}: {heading}")),
)
.collect();
Expand All @@ -665,14 +704,7 @@ fn clean_mdtest_blocks_run() {
None => failures.lock().unwrap().push(report),
};
let next = AtomicUsize::new(0);
// each block alternates a cpu-bound `by transpile` with a wait on the python
// subprocess, and `by` startup also reads typeshed from disk — so a little
// oversubscription past the core count overlaps that i/o and keeps the cores
// busy, while the cap bounds how many processes are ever live at once
let workers = std::thread::available_parallelism()
.map_or(4, |n| n.get() * 2)
.clamp(1, 16)
.min(total);
let workers = SHARD_WORKERS.min(total);

// work-stealing pool: each worker claims the next index and processes it
// until the list is drained. the closures borrow the shared state (the
Expand Down Expand Up @@ -774,15 +806,15 @@ fn clean_mdtest_blocks_run() {
let refused = refused.into_inner().expect("refusals mutex poisoned");
let diverged = diverged.into_inner().expect("divergences mutex poisoned");
for (entry, (file, heading, floor, _)) in REQUIRES.iter().enumerate() {
if minor < *floor && !refused[entry] {
if shard_of(file) == shard && minor < *floor && !refused[entry] {
failures.push(format!(
"{file}: {}: no block is refused below 3.{floor} any more; remove it from `REQUIRES`",
heading.unwrap_or("every section")
));
}
}
for (entry, (file, heading, floor, _)) in DIVERGES_BELOW.iter().enumerate() {
if minor < *floor && !diverged[entry] {
if shard_of(file) == shard && minor < *floor && !diverged[entry] {
failures.push(format!(
"{file}: {heading}: no block diverges below 3.{floor} any more; remove it from \
`DIVERGES_BELOW`"
Expand Down
Loading
Loading