autosetup: converge the compilation-workaround loop instead of timing out - #134
autosetup: converge the compilation-workaround loop instead of timing out#134shellygr wants to merge 8 commits into
Conversation
A contract pinned to an absent compiler was rewritten to whichever compiler was default, regardless of what its pragma allows. For an exact pragma the rewrite cannot compile, so the next pass re-detects the mismatch and re-pins — the two workarounds undo each other until the retry budget or the job timeout ends it. The fallback is now a per-contract plan: each pinned contract is offered only compilers its pragma admits, and an unreadable or unparseable pragma still takes the first candidate. When a contract has no viable substitute the run raises UnsatisfiableSolcPinError naming the contract, its pragma and the binary to install, instead of retrying a substitution that cannot work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The loop compares each pass against its own starting state, so it only notices a pass that changed nothing. Two workarounds that undo each other each change the conf relative to their own baseline, and alternate across passes, so the loop runs to the retry budget — one full certoraRun per pass. Record every (workaround, conf delta) applied in the run. A pass whose changes have all been made before means something is undoing them, so stop there and name them. A pass that also lands a new change is still converging and continues. missing_library_harness is exempt via progress_outside_conf: regenerating its harness covers one more library each time, which is real progress the conf does not show. Its own _harnessed_libs guard bounds it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six defects found by review of the two commits below it: - Candidate order put whatever `solc` is on PATH ahead of the project's own default, so a wide pragma could pick an unrelated compiler. The default now comes first and plain solc is the last resort. - A non-UTF-8 source (an accented byte in a header comment) raised out of read_pragma_from_source_file and killed the loop; it now reads as an unknown pragma. - parse_pragma_constraint understands `=X.Y.Z` and `~X.Y.Z`, and reports a disjunction as unknown rather than mis-parsing it. - The terminal raise is gated on a pin the conf actually carries, so a pin seeded from the default compiler no longer fails the run. - certora-fixconf reports the unsatisfiable pin and still writes back the fixes already applied, instead of exiting on a traceback. - The change ledger is scoped to one run of the loop: fixconf runs it twice on one manager, and the second run may legitimately re-apply the first's changes. An exempt workaround no longer vetoes repeat detection for the whole pass — it only keeps its own changes out of the ledger. The note on missing_library_harness now states what its guard keys on and that max_retries is what bounds it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class BlockedPin: |
There was a problem hiding this comment.
Can you rename it e.g. to BlockedSolcPin? BlockedPin is quite generic
There was a problem hiding this comment.
Claude answers: renamed to BlockedSolcPin in e4ee229. UnsatisfiableSolcPinError already owned that spelling in the module, so it is the house form.
| """ | ||
|
|
||
| failed_solc: str | ||
| rewrites: Dict[str, str] |
There was a problem hiding this comment.
I don't like the name rewrites - how about compliant or something like that?
There was a problem hiding this comment.
alright, after reading more of the codebase, rewrites makes some sense, but here it's still confusing, especially in connection with the comment saying:
rewritescovers the contracts an installed compiler can serve
There was a problem hiding this comment.
Claude answers: it is compliant now, in e4ee229, and the class docstring was rewritten so the prose and the field agree — it opens with what the plan is (which installed compiler, if any, stands in for the missing one at each contract pinned to it), then says compliant maps such a contract to an installed compiler its pragma admits and blocked holds the ones no installed compiler can serve. rewrites was describing the edit the apply performs on compiler_map, whereas the field is the decision, computed a stage earlier by a planner that writes nothing.
| """Sorted (path, new value) for every key an apply added or changed, plus | ||
| (path, '<removed>') for keys it deleted. | ||
|
|
||
| The previous value is deliberately absent: a change is identified by what it |
There was a problem hiding this comment.
the comment describes more how the function is used rather than describing what the function does. Especially the second paragraph of the comment.
Saying it computes the diff and perhaps providing a particular small example would be better.
There was a problem hiding this comment.
Claude answers: that docstring is gone — _conf_delta and _flatten_conf were deleted along with the change ledger in e4ee229. See the reply on the convergence thread for why.
| self._solc_candidates: Optional[List[Tuple[str, str]]] = None | ||
| # (workaround name, conf delta) for every application in this run. Seeing one | ||
| # twice means a later workaround undid it, so the loop is circling rather than | ||
| # converging — see the repeat check in run_compilation_with_workarounds. |
There was a problem hiding this comment.
re-setting the same value of a particular conf field might still lead to a convergence assuming some of the other fields also changed, i.e. perhaps it would make sense to simply store all the already seemed "full" conf files (normalized ofc) and stop once we see a conf we have seen before? That's looser stopping criterion.
There was a problem hiding this comment.
Claude answers: you were right, and the ledger is gone as of e4ee229. The loop now keeps every state it has compiled and stops when one comes round again. _retry_state already serialized exactly that (cmd plus both conf dicts), and it already carried the invariant that makes the stop sound: an apply that makes real progress must change one of them. That also folds the old "this pass changed nothing" guard into the same check, so there is one criterion instead of two.
On your specific worry: the ledger did not actually have that hole. A repeat only stops the loop when nothing new landed in the same pass, so a re-set value riding along with other changes kept going. But looking for that scenario turned up two others that are real, both measured against the actual workaround table:
-
The ledger could give up on a conf that compiles. Once
compiler_version_mismatchbumps the compiler,cancun_opcode_evm_versionre-adds an identical delta and the pass looks like pure repetition. Ledger: gives up at 3 compiles. State memo: reaches that conf and compiles it on the 4th. "This solc rejects cancun" depends on which compilercompiler_mappins, and two other workarounds write that field, so it is reachable rather than theoretical. -
The ledger's cost scaled with the contract count on the very case it was written for.
_apply_solc_fallback_workaroundreplaces every entry pinned to the missing binary in one apply, while_detect_compiler_version_mismatchre-pins one contract per pass, so the fallback's delta is different every pass and the ledger cannot fire until the same contract comes round again. For 1, 3 and 6 pinned contracts: 4, 8 and 14 compiles. The state memo: 2, 3 and 3. The project that started this mixed pragmas across 53 files.
The looseness you named is real and it is the price: an orthogonal one-way change (a warning flag, an optimizer rung) makes each state unique and buys the cycle one more pass. There are about eight such keys, all one-shot, so it is a constant, and max_retries still bounds it. A false negative that costs a constant number of certoraRuns beats a false positive that abandons a conf which would have compiled.
One consequence worth flagging: dropping the ledger also drops the progress_outside_conf exemption missing_library_harness carried, and nothing in tests/ exercised that workaround at all, so it now has a test of its own. Reviewing it turned up a real defect there, filed separately as #179 — it never regenerates a harness for a second library, because the apply renames the consumer handle it would need to look up on the next firing.
| return None | ||
|
|
||
|
|
||
| def pragma_admits(pragma_spec: str, version: str) -> Optional[bool]: |
There was a problem hiding this comment.
I am surprised we did not have a pragma_admits function yet. are you sure it's not already existing somewhere? (I have not checked)
There was a problem hiding this comment.
Claude answers: checked, and no, it did not exist. What existed was the raw expression Version(v) in parse_pragma_constraint(spec) open-coded at four sites: solc_version_resolver.py:277 and :295 inside resolve_pragma_to_version, enhanced_config_manager.py:849, and certoraRunAutoSolc.py:85. Nothing in the installed certora-cli interprets a pragma at all (it only shells out to solc --version), and no semver library is a dependency — packaging is the only thing available.
Naming it is what buys the two things the fallback planner needs and that a caller-side membership test does not give you: None meaning 'spec not understood', never conflated with False, and a guard around Version(version), which raises on a non-PEP440 string. resolve_pragma_to_version cannot serve either, since it returns None both for an unparseable pragma and for 'no matching version found', and it fetches the version list over the network on the way.
parse_pragma_constraint stays public regardless: certoraRunAutoSolc.py:73-85 needs the SpecifierSet objects to intersect many pragmas against many versions. One follow-up worth doing separately: enhanced_config_manager.py:841-851 could drop its own inline membership test and call pragma_admits, which would make it the single entry point.
Unrelated find on the same function, fixed in e4ee229: the third docstring example claimed pragma_admits("~0.6.4", "0.6.4") returns None, but this branch added tilde parsing, so it returns True. It now shows the disjunction case instead, which is the real unknown.
The loop now remembers every state it has compiled and gives up when one comes round again. That is what _retry_state already serializes, and it is the same criterion the no-op guard used, widened from "this pass's own start" to "any state this loop has compiled", so the two checks collapse into one. The change ledger this replaces could give up on a conf that compiles. Once compiler_version_mismatch bumps the compiler, cancun_opcode_evm_version re-adds an identical delta, the pass looks like pure repetition, and the loop stops at 3 compiles. The memo reaches the same conf and compiles it on the 4th. The ledger's cost also grew with the contract count on the case it was written for, since the fallback replaces every pinned entry at once while the mismatch re-pins one per pass: 1, 3 and 6 pinned contracts cost 4, 8 and 14 compiles, against 2, 3 and 3 for the memo. Also renames BlockedPin to BlockedSolcPin and SolcFallbackPlan.rewrites to compliant, and corrects a pragma_admits docstring example that this branch's own tilde support had already invalidated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two conflicts, both in the same region of the retry loop, both resolved by keeping each side whole: #131 added a terminal check for a contract that leaves an inherited function unimplemented, next to the abstract-main-contract check this branch's unsatisfiable-solc-pin check also sits beside. All three now run in order, each raising before any workaround is applied, and none reads another's state. The test file conflict was the two imports landing on the same line. #164 changed how remapping contexts are expressed and touched a different function, so it merged on its own. Convergence re-measured against the merged module and unchanged: the conf the change ledger used to abandon still compiles on the 4th run, and the incident still converges in 2, 3 and 3 compiles at 1, 3 and 6 pinned contracts. Full suite 966 passed, 12 skipped; pyright 0 errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One conflict, and it is a real one: #168 rewrote the same guard this branch replaces. It kept the "did this pass change anything" condition and appended an unresolved-import diagnosis to the message the loop gives up with. This branch drops that condition for a memo of the states the loop has already compiled. Kept both. The memo is the condition, since it fires everywhere the old check did and in the cycles it could not see, and #168's diagnosis moves inside it unchanged: whenever the loop gives up here it still says which imports could not be resolved, and still refreshes last_import_diagnostics on the way out, which is what setup_prover reads to name the failure class. Convergence re-measured against the merged module and unchanged: the conf the change ledger used to abandon still compiles on the 4th run, and the incident still converges in 2, 3 and 3 compiles at 1, 3 and 6 pinned contracts. Full suite 1038 passed, 12 skipped; pyright 0 errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Shelly merged #135 into this branch from the web UI while the same master lineage was being merged locally, alongside #168 and #169. The two are the same content: this commit takes the branch history and leaves the tree exactly as the local merge left it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The incident
A corpus project mixing
pragma solidity 0.6.4;(37 files) and0.8.17(16 files) burned a full60-minute job timeout in the compilation-workaround loop without ever converging:
solc6.4is not installed. The fallback was version-blind: it rewrote every contract pinned to themissing binary to whatever compiler was default, with no reference to the pragma, so the substitution
could never compile and the mismatch detector re-pinned it every pass.
The existing guard could not see this. It compares a pass against its own starting state, so it
only catches a pass that changed nothing. Two workarounds that undo each other each change the conf
relative to their own baseline, and they alternate across passes.
The fixes
1. The fallback is a per-contract plan. Each contract pinned to the missing binary is offered
only compilers its pragma admits (
pragma_admits->True/False/None, whereNonemeansthe spec could not be parsed and is treated as no evidence). When a contract has no viable
substitute the run raises
UnsatisfiableSolcPinErrornaming the contract, its pragma and the binaryto install, rather than retrying a substitution that cannot work.
2. The loop remembers the states it has compiled.
_retry_statealready serializes the commandand both conf dicts, and already carries the invariant that makes this sound: an apply that makes
real progress must change one of them. The loop keeps every state it has run and gives up when one
comes round again, which subsumes the old "this pass changed nothing" guard. One check, not two.
The regression test drives the real shape (missing pin, substitute, re-pin) with the pragma
unreadable so the pragma guard deliberately does not apply: 2 compiles instead of 273.
What replaced the change ledger, and why
The first version of fix 2 was a change ledger: every apply recorded
(workaround, conf delta), anda pass whose changes had all been made before stopped the loop. @jar-ben asked why the criterion was
not simply "stop when a normalized conf repeats". Investigating that turned up two problems with the
ledger, both measured against the real workaround table:
compiler_version_mismatchbumps the compiler,cancun_opcode_evm_versionre-adds an identical delta and the pass looks like pure repetition. Theledger stops at 3 compiles; the state memo reaches that conf and compiles it on the 4th. Whether a
compiler rejects
cancundepends on which compilercompiler_mappins, and two other workaroundswrite that field, so this is reachable rather than theoretical.
every entry pinned to the missing binary in one apply, while the mismatch detector re-pins one
contract per pass, so the fallback's delta differs every pass. For 1, 3 and 6 pinned contracts the
ledger needs 4, 8 and 14 compiles. The state memo needs 2, 3 and 3.
The state memo has the opposite failure mode: an orthogonal one-way change (a warning flag, an
optimizer rung) makes each state unique and buys the cycle one more pass. There are about eight such
keys, all one-shot, so that cost is a constant, and
max_retriesstill bounds it.Validation
uv run --no-sync python -m pytest tests/ -m "not expensive" -q-> 518 passed, 9 skipped;pyright-> 0 errorspragmas, candidate ordering), the state memo stopping a cycle, an orthogonal change letting the
loop continue, and the memo being scoped to one loop rather than to the manager
the exemption it carried and nothing in
tests/exercised itabandoned conf now compiles, and the incident converges in 2, 3 and 3 compiles at 1, 3 and 6
pinned contracts
Known, not fixed here
missing_library_harnessnever regenerates a harness for a second library, and its_harnessed_libsguard cannot bound the
FooHarnessHarnesscascade. Pre-existing and independent of these commits;filed as #179.