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
4 changes: 3 additions & 1 deletion src/coretrace_python/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.source import FileLocation, Location, SourceId, SourceSpan

CACHE_FORMAT = 16
# Cached summaries from earlier versions preserve unstable external-call
# insertion order. They must be recomputed with canonical ordering (#261).
CACHE_FORMAT = 17


@dataclass(frozen=True)
Expand Down
29 changes: 26 additions & 3 deletions src/coretrace_python/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
discover_sources,
name_modules,
project_symbol,
starting_summaries,
)
from coretrace_python.ir import PYIR_SCHEMA_VERSION
from coretrace_python.ir.defuse import DefUseAnalysis
Expand Down Expand Up @@ -184,7 +185,6 @@
_PROJECT_DEPENDANTS: frozenset[AnyAnalysis] = frozenset(
a for a in ALL_ANALYSES if a.name in _PROJECT_DEPENDANT_NAMES
)
MAX_PROJECT_ITERATIONS = 20


class ProjectSummariesUpdated(TransformationPass):
Expand Down Expand Up @@ -831,10 +831,33 @@ def _analyse_managers(

counts = Counter(manager.module.name for manager in managers.values())
names = {name: manager.module.name for name, manager in managers.items() if counts[manager.module.name] == 1}
index = seed
# The members' functions start from the summaries ``SummaryAnalysis`` starts from.
# Left out, a call from one member to another is a call to an unknown symbol on the
# first iteration and a project call on the next: around a cycle of modules, the
# iteration then shifts by one module each time and never settles (#261).
index = SummaryIndex(
{
**seed.summaries,
**{
project_symbol(module, function): summary
for name, module in names.items()
for function, summary in starting_summaries(managers[name].get(CallGraphAnalysis)).items()
},
}
)
for manager in managers.values():
manager.provide(ProjectSummaries, index)
for _ in range(MAX_PROJECT_ITERATIONS):
# Iterated until the index settles, with no bound: stopped earlier, summaries that
# started empty would miss what later iterations add, a dependency still on its way
# reading as no taint. It settles because each iteration can only add: the index holds
# the same symbols from the start, and in every round of a module's fixpoint the rules
# that read the index join what they read (a call's dependencies and records, heap
# stores, merges), while those that do not join (a nonlocal record not summarized yet,
# ``kept``, reachability, ``unsupported``, ``static``) depend on the code and the round
# only. Each round's table thus grows with the index, the fixpoint too, in a finite
# domain: the parameters, symbols and call records of the code. A rule reading the
# index without joining would break this, and the loop's termination with it.
while True:
updated = SummaryIndex(
{
**seed.summaries,
Expand Down
2 changes: 2 additions & 0 deletions src/coretrace_python/interprocedural/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
TemplateCallsAnalysis,
TemplateFilter,
cleared_by,
starting_summaries,
)

__all__ = [
Expand Down Expand Up @@ -78,4 +79,5 @@
"discover_sources",
"name_modules",
"project_symbol",
"starting_summaries",
]
66 changes: 51 additions & 15 deletions src/coretrace_python/interprocedural/summaries.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,30 @@ def named(self) -> Iterable[tuple[str | None, Dependencies, Cleared]]:
return ((name, deps, c) for (name, deps), c in _aligned(self.keyword_dependencies, self.keyword_cleared))


def _canonical_external_calls(calls: Iterable[ExternalCall]) -> tuple[ExternalCall, ...]:
"""Order by call identity so recursive summaries reach stable equality.

A callee's external calls get copied into its callers, potentially in a
different insertion order on each fixpoint round even when the content
is already stable. Comparing unsorted tuples can therefore loop forever.
"""
def span_key(span: SourceSpan | None) -> tuple[str, int, int, int, int]:
if span is None:
return ("", 0, 0, 0, 0)
return (
span.source_id.value, span.start_line, span.start_column,
span.end_line or 0, span.end_column or 0,
)

return tuple(sorted(
calls,
key=lambda call: (
span_key(call.location), span_key(call.call_site),
span_key(call.invoked_at), call.symbol.canonical_name,
),
))


@dataclass(frozen=True)
class Mutation:
"""What the function stores into ``field`` of its ``parameter``: data of other
Expand Down Expand Up @@ -826,7 +850,7 @@ def summarize(
name,
len(function.parameters),
problem.returns.parameters,
tuple(problem.external.values()),
_canonical_external_calls(problem.external.values()),
return_externals=problem.returns.externals,
mutations=tuple(mutations),
side_effects=frozenset(problem.mutated_globals),
Expand All @@ -839,6 +863,21 @@ def summarize(
)


def starting_summaries(graph: CallGraph) -> dict[str, FunctionSummary]:
"""The summary each function of ``graph`` starts from in a fixpoint: a supported
function reaches nothing yet, the bottom its summary grows from; an unsupported one
passes every parameter to its return value and keeps that summary."""

table: dict[str, FunctionSummary] = {}
for name, function in graph.definitions.items():
count = len(function.parameters)
if name in graph.unsupported:
table[name] = FunctionSummary(name, count, frozenset(range(count)), (), True)
else:
table[name] = FunctionSummary(name, count, NONE, ())
return table


class SummaryAnalysis(Analysis[SummaryTable]):
name: ClassVar[str] = "interprocedural.summaries"
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset(
Expand All @@ -860,20 +899,17 @@ def compute(cls, ctx: AnalysisContext) -> SummaryTable:
project = ctx.get(ProjectSummaries).summaries
clearing = ctx.get(ClearingAnalysis)
templates = ctx.get(TemplateCallsAnalysis)
table: dict[str, FunctionSummary] = {}
supported: dict[str, tuple[FunctionIR, CFG, HeapFacts, Callable[[BlockId], bool]]] = {}
for name, function in graph.definitions.items():
if name in graph.unsupported:
count = len(function.parameters)
table[name] = FunctionSummary(name, count, frozenset(range(count)), (), True)
else:
supported[name] = (
ctx.get(SSAAnalysis, function),
ctx.get(CFGAnalysis, function),
ctx.get(HeapAnalysis, function),
ctx.get(ConstantPropagation, function).reachable,
)
table[name] = FunctionSummary(name, len(function.parameters), NONE, ())
table = starting_summaries(graph)
supported: dict[str, tuple[FunctionIR, CFG, HeapFacts, Callable[[BlockId], bool]]] = {
name: (
ctx.get(SSAAnalysis, function),
ctx.get(CFGAnalysis, function),
ctx.get(HeapAnalysis, function),
ctx.get(ConstantPropagation, function).reachable,
)
for name, function in graph.definitions.items()
if name not in graph.unsupported
}
changed = True
while changed:
changed = False
Expand Down
114 changes: 114 additions & 0 deletions tests/test_summary_external_call_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Regression for issue #261: an external-call cycle must reach a fixpoint.

The child-process timeout makes the old nontermination observable without
leaving a hanging pytest process on CI. The source is a fixed, local fixture.
"""

from __future__ import annotations

import subprocess
import sys
import tempfile
import textwrap
import unittest
from pathlib import Path

SOURCE = textwrap.dedent("""\
def a(x):
b(x)
print(x)

def b(x):
c(x)
print(x)

def c(x):
a(x)
""")


class ExternalCallOrderTests(unittest.TestCase):
def test_old_order_dependent_summary_cache_is_invalidated(self):
from coretrace_python.cache import CACHE_FORMAT, decode

self.assertGreater(CACHE_FORMAT, 16)
with self.assertRaisesRegex(ValueError, "unsupported cache format"):
decode({"format": 16})

def test_three_function_cycle_converges_with_external_calls(self):
program = (
"from coretrace_python import engine\n"
"from coretrace_python.frontend import build_hir\n"
"from coretrace_python.source import SourceManager\n"
"from coretrace_python.interprocedural import SummaryAnalysis\n"
f"source = {SOURCE!r}\n"
"table = engine.build_manager(build_hir(SourceManager().add_source('cycle.py', source))).get(SummaryAnalysis)\n"
"for name in ('a', 'b', 'c'):\n"
" calls = table.summary(name).external_calls\n"
" assert any(str(c.symbol) == 'python.builtins.print' for c in calls), name\n"
"print('converged')\n"
)
try:
proc = subprocess.run(
[sys.executable, "-c", program], capture_output=True,
text=True, timeout=20, check=False,
)
except subprocess.TimeoutExpired as exc:
self.fail(f"summary fixpoint did not terminate within 20 seconds: {exc}")
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertEqual(proc.stdout.strip(), "converged")

def test_external_call_order_is_independent_of_insertion_order(self):
from coretrace_python.interprocedural.summaries import (
ExternalCall,
_canonical_external_calls,
)
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.source import SourceId, SourceSpan

span = SourceSpan(SourceId("cycle.py"), 3, 5)
earlier = ExternalCall(SymbolId("python.builtins.print"), (), (), span, None)
later = ExternalCall(
SymbolId("python.builtins.len"), (), (),
SourceSpan(SourceId("cycle.py"), 6, 5), span,
)
self.assertEqual(
_canonical_external_calls([earlier, later]),
_canonical_external_calls([later, earlier]),
)
self.assertEqual(_canonical_external_calls([later, earlier]), (earlier, later))

def test_public_check_command_finishes_for_recursive_external_calls(self):
with tempfile.TemporaryDirectory() as root:
path = Path(root) / "cycle.py"
path.write_text(SOURCE, encoding="utf-8")
try:
proc = subprocess.run(
[sys.executable, "-m", "coretrace_python", "--check", str(path)],
capture_output=True, text=True, timeout=20, check=False,
)
except subprocess.TimeoutExpired as exc:
self.fail(f"CLI --check stalled on 3-function call cycle: {exc}")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)

def test_order_is_stable_across_call_site_and_symbol_ties(self):
from coretrace_python.interprocedural.summaries import (
ExternalCall,
_canonical_external_calls,
)
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.source import SourceId, SourceSpan

location = SourceSpan(SourceId("cycle.py"), 3, 5)
caller = SourceSpan(SourceId("cycle.py"), 6, 5)
first = ExternalCall(SymbolId("python.builtins.len"), (), (), location, None)
second = ExternalCall(SymbolId("python.builtins.print"), (), (), location, None)
third = ExternalCall(SymbolId("python.builtins.len"), (), (), location, caller)
self.assertEqual(
_canonical_external_calls((third, second, first)),
(first, second, third),
)


if __name__ == "__main__":
unittest.main()
Loading
Loading