Skip to content

Commit ab8e4bf

Browse files
[mypyc] Fix missing cross-group header deps in incremental builds (#21490)
## Problem In `separate=True` mode, each group's `__native_internal_<mod>.h` reaches into a sibling group's export-table header via an angled include: ```c // __native_internal_caller.h #include <other_group/__native_other.h> ``` The consumer's `.o` file bakes in byte offsets from that struct at C compile time. If the sibling group's struct layout shifts (e.g. a class is inserted earlier in the source, shifting all subsequent offsets), the consumer's `.o` must be recompiled, otherwise it silently resolves offsets to the wrong object at runtime. Two bugs in the old `get_header_deps` combined to hide this dep from setuptools' `Extension.depends`: 1. **No transitive walk.** The cross-group include lives inside `__native_internal_<mod>.h`, not in the `.c` file itself. The old code only scanned the `.c` file's direct includes and never opened the resolved headers. 2. **Single-pass resolution.** Deps were resolved per-group before sibling groups had written their headers to disk, so the cross-group header didn't yet exist even if we had looked. ## Fix - **`_extract_includes` / `_INCLUDE_RE`**: distinguishes `#include "foo"` (quoted) from `#include <foo>` (angled), matching the preprocessor's own search rules — quoted form searches the includer's directory first; angled form searches `-I` paths only. - **`resolve_cfile_deps`**: transitive walker that opens each resolved header and follows its `#include` directives, bounded by a visited set. Headers that don't exist under `target_dir` (lib-rt headers like `<Python.h>`) are dropped since they never change between builds. - **Two-pass loop in `mypyc_build`**: first pass writes all groups' files to disk; second pass resolves deps so every group's headers are present when cross-group includes are looked up. ## Impact on non-incremental builds None. `Extension.depends` is only consulted by setuptools when a `.o` already exists. Cold builds compile everything unconditionally. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent a9d2d14 commit ab8e4bf

4 files changed

Lines changed: 323 additions & 21 deletions

File tree

mypyc/build.py

Lines changed: 133 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -566,22 +566,115 @@ def construct_groups(
566566
return groups
567567

568568

569-
def get_header_deps(cfiles: list[tuple[str, str]]) -> list[str]:
570-
"""Find all the headers used by a group of cfiles.
569+
# Single regex that captures both `#include "foo"` and `#include <foo>`. The
570+
# alternation lets us tell the two forms apart: the quoted-form match populates
571+
# group 1 and the angle-form match populates group 2. The C preprocessor
572+
# applies different search rules to each kind (see `_extract_includes`), so we
573+
# carry the kind through resolution rather than collapsing them up front.
574+
_INCLUDE_RE = re.compile(r'#\s*include\s+(?:"([^"]+)"|<([^>]+)>)')
575+
576+
577+
def _extract_includes(contents: str) -> list[tuple[bool, str]]:
578+
"""Return each `#include` directive's (is_angled, name) from `contents`.
579+
580+
is_angled=False for `#include "foo"`, True for `#include <foo>`.
581+
"""
582+
out: list[tuple[bool, str]] = []
583+
for quoted, angled in _INCLUDE_RE.findall(contents):
584+
if quoted:
585+
out.append((False, quoted))
586+
else:
587+
out.append((True, angled))
588+
return out
589+
590+
591+
def get_header_deps(cfiles: list[tuple[str, str]]) -> list[tuple[bool, str]]:
592+
"""Find all the headers directly included by a group of cfiles.
593+
594+
Returns a sorted, deduplicated list of `(is_angled, header_name)` pairs.
595+
Callers that only need the names can ignore the bool, but it is needed by
596+
`resolve_cfile_deps` to apply the correct preprocessor search order.
571597
572598
We do this by just regexping the source, which is a bit simpler than
573-
properly plumbing the data through.
599+
properly plumbing the data through. Transitive header-to-header includes
600+
are picked up by `resolve_cfile_deps` in `mypyc_build`, which can read
601+
the on-disk headers after every group has written its files.
574602
575603
Arguments:
576-
cfiles: A list of (file name, file contents) pairs.
604+
cfiles: A list of (file name, file contents) pairs. Contents must be
605+
non-empty; callers handling cached groups must re-read the .c
606+
from disk before calling, otherwise direct includes are missed
607+
and Extension.depends ends up empty.
577608
"""
578-
headers: set[str] = set()
609+
assert all(
610+
contents for _, contents in cfiles
611+
), "get_header_deps requires non-empty file contents"
612+
headers: set[tuple[bool, str]] = set()
579613
for _, contents in cfiles:
580-
headers.update(re.findall(r'#include [<"]([^>"]+)[>"]', contents))
614+
headers.update(_extract_includes(contents))
581615

582616
return sorted(headers)
583617

584618

619+
def resolve_cfile_deps(
620+
cfile_dir: str, direct_includes: list[tuple[bool, str]], target_dir: str
621+
) -> set[str]:
622+
"""
623+
Resolve a .c file's `#include`s to on-disk paths, walking transitively through resolved headers.
624+
625+
The C preprocessor resolves `#include "foo"` against the includer's directory first, then via
626+
-I, while `#include <foo>` only uses -I. We mirror that exactly: quoted includes are searched
627+
in (includer_dir, target_dir) order, and angled includes are searched in target_dir only.
628+
`target_dir` is the only -I path that holds files we generate; anything we cannot resolve under
629+
it (or, for quoted form, the includer's dir) is dropped. Other headers like `<Python.h>` and
630+
`<CPy.h>` live elsewhere and do not change between builds, so they are not real dependencies
631+
for incremental purposes.
632+
633+
The walk is transitive: each resolved header is opened and scanned for its own `#include`
634+
directives. Without this, cross-group export-table headers reached via `__native_internal_<mod>.h`
635+
(which includes `<other_group/__native_other.h>`) would be missed, and edits that shift struct
636+
offsets in `other_group` would not trigger a recompile of the consumer's .o file. Its baked-in
637+
offsets would then resolve to whatever class/function now occupies that slot => runtime corruption.
638+
639+
Returns a set of resolved paths suitable for use as an Extension.depends list.
640+
"""
641+
resolved: set[str] = set()
642+
643+
# Worklist of (search_dir, is_angled, header_name). search_dir is the includer's directory; for the
644+
# initial cfile it is the cfile's dir, for a transitively-included header it is that header's dir.
645+
# It is only consulted for quoted-form includes.
646+
worklist: list[tuple[str, bool, str]] = [
647+
(cfile_dir, is_angled, dep) for is_angled, dep in direct_includes
648+
]
649+
650+
while worklist:
651+
search_dir, is_angled, dep = worklist.pop()
652+
# Quoted form: includer's dir first, then -I (target_dir).
653+
# Angled form: -I only (skips the includer's dir).
654+
search_bases = (target_dir,) if is_angled else (search_dir, target_dir)
655+
for base in search_bases:
656+
candidate = os.path.normpath(os.path.join(base, dep))
657+
if not os.path.exists(candidate):
658+
continue
659+
if candidate in resolved:
660+
break
661+
resolved.add(candidate)
662+
# Recurse only into headers. Some lib-rt sources are pulled in as `#include "init.c"` etc.;
663+
# those do not resolve under target_dir so they get filtered out before we would try to scan
664+
# them, but the .h guard is a cheap belt-and-braces.
665+
if candidate.endswith(".h"):
666+
try:
667+
with open(candidate, encoding="utf-8") as f:
668+
header_contents = f.read()
669+
except OSError:
670+
header_contents = ""
671+
sub_dir = os.path.dirname(candidate)
672+
for sub_angled, sub in _extract_includes(header_contents):
673+
worklist.append((sub_dir, sub_angled, sub))
674+
break
675+
return resolved
676+
677+
585678
def mypyc_build(
586679
paths: list[str],
587680
compiler_options: CompilerOptions,
@@ -630,29 +723,48 @@ def mypyc_build(
630723
for (path, dirs, internal) in skip_cgen_input[1]
631724
]
632725

633-
# Write out the generated C and collect the files for each group
726+
# Write out the generated C and collect the files for each group.
634727
# Should this be here??
635-
group_cfilenames: list[tuple[list[str], list[str]]] = []
728+
#
729+
# Header resolution is deferred to a second pass: a header in one group may include a header
730+
# generated by another group, so resolving here misses cross-group deps for groups processed first.
731+
pending: list[list[tuple[str, list[tuple[bool, str]]]]] = []
636732
for cfiles in group_cfiles:
637-
cfilenames = []
733+
per_cfile_deps: list[tuple[str, list[tuple[bool, str]]]] = []
638734
for cfile, ctext in cfiles:
639735
cfile = os.path.join(compiler_options.target_dir, cfile)
736+
640737
# Empty contents marks a file the previous run already wrote
641738
# (fully-cached group): skip the rewrite and just reuse it.
642739
if ctext and not options.mypyc_skip_c_generation:
643740
write_file(cfile, ctext)
644-
if os.path.splitext(cfile)[1] == ".c":
645-
cfilenames.append(cfile)
646-
647-
# The header regex matches both quote styles, so the result can
648-
# include system headers like `<Python.h>` that don't live under
649-
# target_dir. Joining those produces non-existent paths which
650-
# would force a full rebuild on every run via Extension.depends.
651-
candidate_deps = (
652-
os.path.join(compiler_options.target_dir, dep) for dep in get_header_deps(cfiles)
653-
)
654-
deps = [d for d in candidate_deps if os.path.exists(d)]
655-
group_cfilenames.append((cfilenames, deps))
741+
742+
# For fully-cached groups ctext is empty; read the on-disk .c so the dep resolver
743+
# can walk its transitive header chain and populate Extension.depends. Otherwise,
744+
# cross-group export-table header changes (e.g. a new class shifting struct offsets)
745+
# won't trigger a recompile of this cached consumer's .o.
746+
if not ctext and os.path.exists(cfile):
747+
try:
748+
with open(cfile, encoding="utf-8") as _f:
749+
ctext = _f.read()
750+
except OSError:
751+
pass
752+
per_cfile_deps.append((cfile, get_header_deps([(cfile, ctext)])))
753+
pending.append(per_cfile_deps)
754+
755+
# Second pass: assemble each group's .c filenames and resolve transitive deps now that every group's
756+
# headers are on disk. See resolve_cfile_deps for the rules.
757+
group_cfilenames: list[tuple[list[str], list[str]]] = []
758+
for per_cfile in pending:
759+
cfilenames = [cf for cf, _ in per_cfile if os.path.splitext(cf)[1] == ".c"]
760+
deps_set: set[str] = set()
761+
for cfile_full, dep_names in per_cfile:
762+
deps_set.update(
763+
resolve_cfile_deps(
764+
os.path.dirname(cfile_full), dep_names, compiler_options.target_dir
765+
)
766+
)
767+
group_cfilenames.append((cfilenames, sorted(deps_set)))
656768

657769
return groups, group_cfilenames, source_deps
658770

mypyc/test-data/run-multimodule.test

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1391,6 +1391,60 @@ def translate(b: bytes) -> bytes:
13911391
import native
13921392
assert native.translate(b'ABCD') == b'BBCD'
13931393

1394+
[case testIncrementalCrossGroupExportTableOffsets]
1395+
# Regression: under separate=True, each consumer module's IR is
1396+
# compiled against the positional layout of its deps'
1397+
# `exports_<group>` struct. Reordering the dep's classes keeps the
1398+
# same set of public names, so mypy's interface hash for the dep is
1399+
# unchanged -- the consumer is not invalidated and stays fully
1400+
# cached, which causes `_load_cached_group_files` to return empty
1401+
# cfile content for the consumer's group.
1402+
#
1403+
# Before the fix, `get_header_deps` over empty content returned no
1404+
# includes, so `Extension.depends` for the consumer ended up empty
1405+
# and setuptools never recompiled the consumer's .o when the dep's
1406+
# `__native_<dep>.h` shifted struct offsets. The stale .o kept the
1407+
# old offsets and silently resolved cross-group calls to the wrong
1408+
# class.
1409+
from other_classes import Gamma, Delta
1410+
1411+
def make_gamma() -> Gamma:
1412+
return Gamma()
1413+
1414+
def make_delta() -> Delta:
1415+
return Delta()
1416+
1417+
[file other_classes.py]
1418+
class Alpha:
1419+
a: int = 1
1420+
1421+
class Beta:
1422+
b: int = 2
1423+
1424+
class Gamma:
1425+
g: int = 3
1426+
1427+
class Delta:
1428+
d: int = 4
1429+
1430+
[file other_classes.py.2]
1431+
class Delta:
1432+
d: int = 4
1433+
1434+
class Alpha:
1435+
a: int = 1
1436+
1437+
class Beta:
1438+
b: int = 2
1439+
1440+
class Gamma:
1441+
g: int = 3
1442+
1443+
[file driver.py]
1444+
import native
1445+
assert type(native.make_gamma()).__name__ == "Gamma"
1446+
assert type(native.make_delta()).__name__ == "Delta"
1447+
13941448
[case testCrossModuleAttrDefaults]
13951449
from other import Parent
13961450

mypyc/test/test_misc.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from __future__ import annotations
22

3+
import os
4+
import tempfile
35
import unittest
46

7+
from mypyc.build import get_header_deps, resolve_cfile_deps
58
from mypyc.ir.ops import BasicBlock
69
from mypyc.ir.pprint import format_blocks, generate_names_for_ir
710
from mypyc.irbuild.ll_builder import LowLevelIRBuilder
@@ -20,3 +23,130 @@ def test_debug_op(self) -> None:
2023
names = generate_names_for_ir([], [block])
2124
code = format_blocks([block], names, {})
2225
assert code[:-1] == ["L0:", " r0 = 'foo'", " CPyDebug_PrintObject(r0)"]
26+
27+
28+
class TestHeaderDeps(unittest.TestCase):
29+
"""
30+
Tests for the header-dependency tracking used to build `Extension.depends`, which drives
31+
setuptools' `newer_group` decision about whether to recompile a .o file on incremental builds.
32+
"""
33+
34+
def test_get_header_deps_quoted_includes(self) -> None:
35+
# Quoted includes: the historical form. Used by the .c file to reach its own __native_<mod>.h /
36+
# __native_internal_<mod>.h. The `False` in each tuple marks the include as non-angled, which
37+
# `resolve_cfile_deps` uses to search the includer's directory.
38+
cfile = '#include "__native_caller.h"\n#include "__native_internal_caller.h"\n'
39+
assert get_header_deps([("caller.c", cfile)]) == [
40+
(False, "__native_caller.h"),
41+
(False, "__native_internal_caller.h"),
42+
]
43+
44+
def test_get_header_deps_angle_bracket_includes(self) -> None:
45+
# Angle-bracket includes are also matched, and reported with is_angled=True so that the resolver
46+
# skips the includer's dir for them (matching the C preprocessor). The cross-group export header
47+
# is reached via `#include <other_group/__native_other.h>` in __native_internal_<mod>.h. Before
48+
# this was matched the dep was missed entirely and the consumer's .o was never invalidated when
49+
# the other group's struct layout shifted.
50+
cfile = "#include <Python.h>\n#include <lib/__native_functions.h>\n"
51+
assert get_header_deps([("caller.c", cfile)]) == [
52+
(True, "Python.h"),
53+
(True, "lib/__native_functions.h"),
54+
]
55+
56+
def test_get_header_deps_mixed_and_whitespace(self) -> None:
57+
# The preprocessor tolerates whitespace and the leading-hash form. `get_header_deps` returns sorted
58+
# tuples — non-angled (False) sorts before angled (True), then alphabetical within each kind.
59+
cfile = '# include "a.h"\n# include <b.h>\n#include\t"c.h"\n'
60+
assert get_header_deps([("x.c", cfile)]) == [(False, "a.h"), (False, "c.h"), (True, "b.h")]
61+
62+
def test_resolve_walks_transitively_through_headers(self) -> None:
63+
# Reproduces the bug scenario: caller's .c only directly includes caller's own headers, but
64+
# caller's __native_internal_caller.h includes the cross-group export header. The resolver
65+
# must follow that chain so setuptools sees the cross-group header as a dep.
66+
with tempfile.TemporaryDirectory() as tmp:
67+
build_dir = tmp
68+
os.makedirs(os.path.join(build_dir, "lib"))
69+
os.makedirs(os.path.join(build_dir, "other_group"))
70+
71+
# caller.c's directly-included headers, both live alongside
72+
# caller.c under build/ (resolved via target_dir).
73+
internal_h = os.path.join(build_dir, "__native_internal_caller.h")
74+
caller_h = os.path.join(build_dir, "__native_caller.h")
75+
cross_group_h = os.path.join(build_dir, "lib", "__native_functions.h")
76+
unrelated_h = os.path.join(build_dir, "other_group", "__native_other.h")
77+
78+
with open(caller_h, "w") as f:
79+
# Headers outside build/ (CPython's <Python.h>, lib-rt's <CPy.h>) don't resolve under
80+
# target_dir, so they get dropped during resolution and aren't recursed into.
81+
f.write("#include <Python.h>\n#include <CPy.h>\n")
82+
with open(internal_h, "w") as f:
83+
# This header includes a header in another group via angle brackets. Pre-fix, this dep
84+
# was invisible to setuptools.
85+
f.write(
86+
"#include <Python.h>\n"
87+
'#include "__native_caller.h"\n'
88+
"#include <lib/__native_functions.h>\n"
89+
)
90+
with open(cross_group_h, "w") as f:
91+
f.write("struct export_table_lib___functions { int x; };\n")
92+
with open(unrelated_h, "w") as f:
93+
# Sibling group not reached from caller's chain => must NOT appear in the resolved set.
94+
f.write("struct unrelated { int x; };\n")
95+
96+
# caller.c is in build_dir, so its includer-dir is build_dir. Both directly-included headers
97+
# are quoted (`False`); the cross-group header that __native_internal_caller.h reaches via
98+
# `<lib/__native_functions.h>` is found by the recursive walk re-reading the on-disk header.
99+
deps = resolve_cfile_deps(
100+
cfile_dir=build_dir,
101+
direct_includes=[
102+
(False, "__native_caller.h"),
103+
(False, "__native_internal_caller.h"),
104+
],
105+
target_dir=build_dir,
106+
)
107+
108+
assert deps == {caller_h, internal_h, cross_group_h}, (
109+
f"expected the cross-group header to be reached transitively; "
110+
f"got {sorted(deps)!r}"
111+
)
112+
113+
def test_resolve_drops_unresolvable_includes(self) -> None:
114+
# `<Python.h>`, `<CPy.h>`, etc. don't live under target_dir, so they're dropped from depends. They
115+
# never change between builds, so this is the right behavior. Crucially, it stops setuptools'
116+
# `missing="newer"` from treating them as always-newer and force-rebuilding every translation unit.
117+
with tempfile.TemporaryDirectory() as tmp:
118+
cfile_dir = tmp
119+
deps = resolve_cfile_deps(
120+
cfile_dir=cfile_dir,
121+
direct_includes=[(True, "Python.h"), (True, "CPy.h"), (False, "init.c")],
122+
target_dir=cfile_dir,
123+
)
124+
assert deps == set()
125+
126+
def test_resolve_search_order_matches_preprocessor(self) -> None:
127+
# When the same header name exists both next to the includer and under target_dir, the C preprocessor
128+
# picks the includer-dir copy for `#include "shared.h"` and the target_dir copy for `#include <shared.h>`.
129+
# The resolver must record the same path the compiler will actually consume, otherwise mtimes of the
130+
# wrong file drive incremental rebuild decisions.
131+
with tempfile.TemporaryDirectory() as tmp:
132+
includer = os.path.join(tmp, "groupA")
133+
target = os.path.join(tmp, "build")
134+
os.makedirs(includer)
135+
os.makedirs(target)
136+
137+
local_h = os.path.join(includer, "shared.h")
138+
global_h = os.path.join(target, "shared.h")
139+
with open(local_h, "w") as f:
140+
f.write("/* local */\n")
141+
with open(global_h, "w") as f:
142+
f.write("/* global */\n")
143+
144+
# Quoted form: resolves to the includer-dir copy.
145+
assert resolve_cfile_deps(
146+
cfile_dir=includer, direct_includes=[(False, "shared.h")], target_dir=target
147+
) == {local_h}
148+
149+
# Angled form: skips the includer-dir copy, resolves under -I.
150+
assert resolve_cfile_deps(
151+
cfile_dir=includer, direct_includes=[(True, "shared.h")], target_dir=target
152+
) == {global_h}

0 commit comments

Comments
 (0)