Repository navigation
Conversation
Reports for a conflict involving a package with many versions ran to hundreds of lines, and the lines that did the explaining did not say which versions were at odds. - `simplify` no longer refuses to merge candidates that share a "forbid multiple instances" edge, so a package's versions collapse into one line. - Candidates are expanded once per branch that requires them instead of being silently skipped after the first, so every branch can be read on its own; a cycle guard and a cap on repeats keep that bounded. - "forbid multiple instances" messages are emitted on both sides of the conflict, skip candidates that are installable, and name the conflicting requirements that hold on the same path rather than every version of the package in the report. - Requirements whose text is printed more than once and pointed at by a conflict message get an `(A)`-style label, so the reader can find the line a message refers to.
A conflict message names the requirements that cannot hold at the same time as the candidate it is printed under, and those requirements are spelled out elsewhere in the report. Labels were only handed out when the same requirement text was printed more than once, so in most reports the message listed version sets the reader then had to hunt for. Label every requirement line that is pointed at, list the mentions in the order the report prints the lines they point at so the labels ascend, and print the package name once in front of the list instead of before every entry. Key the line paths on one node per merge group as well: only one member of a group is printed, so a path that arrived at another member never matched the line the report prints and the mention stayed unlabelled.
|
Thanks for this PR. While porting resolvo to Go (goomba) and fuzzing it differentially against resolvo, we hit the unbounded explanation rendering that this PR fixes, and we wrote two regression tests for it. They may be useful to include here. On
On this PR's head (9c793e5) both tests pass. The cyclic case terminates, and the layered case is linear: Both tests render through a Regression tests/// A `fmt::Write` sink that refuses to grow beyond `limit` bytes, so a
/// runaway explanation fails fast instead of exhausting memory.
struct BoundedWriter {
buf: String,
limit: usize,
}
impl std::fmt::Write for BoundedWriter {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
if self.buf.len() + s.len() > self.limit {
return Err(std::fmt::Error);
}
self.buf.push_str(s);
Ok(())
}
}
/// Solves an unsat problem and renders the explanation into a writer capped
/// at `limit` bytes. `Err` holds what was written before the cap was hit.
fn render_unsat_bounded(
mut provider: BundleBoxProvider,
specs: &[&str],
limit: usize,
) -> Result<String, String> {
use std::fmt::Write as _;
let requirements = provider.requirements(specs);
let mut solver = Solver::new(provider);
let problem = Problem::new().requirements(requirements);
let Err(UnsolvableOrCancelled::Unsolvable(conflict)) = solver.solve(problem) else {
panic!("expected the problem to be unsolvable");
};
let mut out = BoundedWriter {
buf: String::new(),
limit,
};
match write!(out, "{}", conflict.display_user_friendly(&solver)) {
Ok(()) => Ok(out.buf),
Err(_) => Err(out.buf),
}
}
/// `a` requires `b`, `b` requires `a` and `c`, and `c` forbids `a`, so the
/// conflict graph contains the cycle a -> b -> a. Rendering the explanation
/// never terminates.
#[test]
fn test_explanation_of_cyclic_conflict_graph_terminates() {
let mut provider = BundleBoxProvider::new();
provider.add_package("a", 2.into(), &["b"], &[]);
provider.add_package("b", 4.into(), &["c", "a"], &[]);
provider.add_package("c", 4.into(), &[], &["a 0..1"]);
if let Err(prefix) = render_unsat_bounded(provider, &["a | b"], 1 << 20) {
let head = prefix.lines().take(14).collect::<Vec<_>>().join("\n");
panic!("the explanation exceeded 1 MiB; it starts with:\n{head}");
}
}
/// Layers of packages `x{i}` and `y{i}`, each requiring `x{i+1} | y{i+1}`;
/// the last layer requires a package that does not exist. Every candidate is
/// expanded again each time the tree reaches it, so the explanation doubles
/// with every layer.
#[test]
fn test_explanation_of_layered_conflict_graph_is_not_exponential() {
let explanation_lines = |layers: usize| {
let mut provider = BundleBoxProvider::new();
for i in 0..layers {
let dep = if i + 1 == layers {
"missing".to_string()
} else {
format!("x{} | y{}", i + 1, i + 1)
};
provider.add_package(&format!("x{i}"), 1.into(), &[dep.as_str()], &[]);
provider.add_package(&format!("y{i}"), 1.into(), &[dep.as_str()], &[]);
}
match render_unsat_bounded(provider, &["x0 | y0"], 1 << 26) {
Ok(s) => s.lines().count(),
Err(_) => usize::MAX,
}
};
let lines: Vec<usize> = (2..=12).map(explanation_lines).collect();
// 2 candidates and 4 requirement edges per layer: a bounded number of
// lines per layer is expected.
assert!(
lines.iter().zip(2..).all(|(&n, layers)| n <= 40 * layers),
"explanation lines for 2..=12 layers: {lines:?}"
);
}Separately, we filed #315, #316 and #317 for solver-side issues found by the same fuzzing. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Unsat reports for a conflict involving a package with many versions ran to hundreds of lines, and the lines that did the explaining did not say which versions were at odds. This makes the conflict report readable.
simplifyno longer refuses to merge candidates that share a "forbid multiple instances" edge, so a package's versions collapse into one line (node 1 | 2 | 3 | ... | 8).unsat_repeated_subtrees_are_cut_off).(A)-style label, listed in the order the report prints them, so the reader can find the line a message refers to. Line paths are keyed on one node per merge group, since only one member of a group is printed.Example
Before (
unsat_pubgrub_article):After:
Many conflicting versions now collapse and name the requirement they conflict with (
unsat_many_conflicting_versions_name_the_requirement):Context
conda/rattler#2870 currently pins this branch via
[patch.crates-io]to produce the new reports; that patch is dropped once this lands and is released.Test plan
main(branch is currently 11 commits behind).