Track what a primary module interface imports #29

Merged
catbot merged 1 commit from claude/issue-26 into master 2026-07-30 18:20:43 +00:00
Member

What was wrong

A primary module interface unit — export module Widget;, no partitions — recorded nothing about what it imported. GetInterfacesAndImplementations registered the Module and then erased the file from the scan list, so the import-resolution pass only ever saw partition files, and Module had no vectors to hold an edge anyway.

Two consequences, both of them issue #26:

  • Module::Check consulted only its own .cppm and its partitions. A data member added to an imported module left Widget.pcm, Widget.o and every consumer object untouched, while the imported library rebuilt and both binaries relinked. One executable, two class layouts, no diagnostic — and a crash far from the cause that stays reproducible until someone wipes build/.
  • Module::Compile waited on nothing. Two modules in one Configuration compile on concurrent threads, so a primary interface importing a sibling was a coin flip between working and fatal error: module 'Base' not found.

Partitions never had either problem — they carry the same three vectors and Check/Compile honour them — which is why the gap only surfaced on a module whose interface is one flat unit. tests/IncrementalInterfaceChange covers the neighbouring consumer-side edge (#27); this is the edge one hop further up.

Reproduction, before the fix

base exports Payload; widget's primary interface does export import Base; and embeds a Payload by value; a test constructs Thing on the stack. Adding 4 KiB to Payload:

[7/8] Compiling interface Base
[8/8] Compiling Base.cpp
[9/9] Linking base
[10/10] Linking widget           <- relinked, never recompiled
[11/11] Linking Consumer         <- relinked, never recompiled
❌ Consumer  first=1 tail=7 sizeof(Thing)=8

sizeof(Thing)=8 is the old layout; first=1 is the corruption — the library's Stamp() wrote at the new offset. Same shape as the issue's crafter-build test 'ShouldSendRecieveHTTP1' log: interface rebuilt, library relinked, test relinked, main.cpp never compiled.

After the fix the same edit produces Compiling interface Widget, Compiling Widget.cpp, Compiling main.cpp and a pass.

The change

  • Module gains moduleDependencies, externalModuleDependencies and pendingImports, with the same meanings they already have on ModulePartition.
  • Primary interface units stay in the scan list so their import lines land on those vectors. All primaries are registered before any import is resolved, so declaration order doesn't matter (a module may import a sibling declared after it).
  • Module::Check sees through the new edges — recursively for a local sibling, by PCM mtime for a dependency's module.
  • Module::Compile orders itself behind a local sibling it imports.
  • ResolvePendingImports sweeps module-level pending names, so the edge survives cfg.dependencies being assigned after the scan.
  • Build() now Checks every interface before spawning any compile thread. The compiled flag a waiter blocks on is raised either by a Compile that runs or by the Check that decides none is needed, so a Check still pending while another module's thread was already waiting would have hung the build outright.
  • needsRecompiling gets a default initializer (it was read-before-write on a dependency cycle).

Verification

  • New tests/TransitiveInterfaceChange: asserts the scan records a primary unit's imports and that pending/resolve reaches them; asserts a sibling import resolves locally even when declared first, and clean-builds four times over to pin the ordering; then end-to-end grows Payload and asserts the importing interface unit, that module's implementation unit and the consumer's TU are all recompiled and the binary agrees on the layout. Against the unfixed library it reports 11 failed assertions.
  • crafter-build test: 19 passed, including the pre-existing 18.
  • crafter-build lint: clean, 42 files, 15 rules.
  • Self-host builds three times from scratch, and the stage-2 binary runs the suite green (19 passed) — the reordered interface pass doesn't deadlock on a real 1-primary/10-partition project.

Resolves #26

🤖 Generated with Claude Code

## What was wrong A *primary* module interface unit — `export module Widget;`, no partitions — recorded nothing about what it imported. `GetInterfacesAndImplementations` registered the `Module` and then **erased the file from the scan list**, so the import-resolution pass only ever saw partition files, and `Module` had no vectors to hold an edge anyway. Two consequences, both of them issue #26: - **`Module::Check` consulted only its own `.cppm` and its partitions.** A data member added to an imported module left `Widget.pcm`, `Widget.o` and every consumer object untouched, while the imported library rebuilt and both binaries relinked. One executable, two class layouts, no diagnostic — and a crash far from the cause that stays reproducible until someone wipes `build/`. - **`Module::Compile` waited on nothing.** Two modules in one `Configuration` compile on concurrent threads, so a primary interface importing a sibling was a coin flip between working and `fatal error: module 'Base' not found`. Partitions never had either problem — they carry the same three vectors and `Check`/`Compile` honour them — which is why the gap only surfaced on a module whose interface is one flat unit. `tests/IncrementalInterfaceChange` covers the neighbouring consumer-side edge (#27); this is the edge one hop further up. ## Reproduction, before the fix `base` exports `Payload`; `widget`'s primary interface does `export import Base;` and embeds a `Payload` by value; a test constructs `Thing` on the stack. Adding 4 KiB to `Payload`: ``` [7/8] Compiling interface Base [8/8] Compiling Base.cpp [9/9] Linking base [10/10] Linking widget <- relinked, never recompiled [11/11] Linking Consumer <- relinked, never recompiled ❌ Consumer first=1 tail=7 sizeof(Thing)=8 ``` `sizeof(Thing)=8` is the old layout; `first=1` is the corruption — the library's `Stamp()` wrote at the new offset. Same shape as the issue's `crafter-build test 'ShouldSendRecieveHTTP1'` log: interface rebuilt, library relinked, test relinked, `main.cpp` never compiled. After the fix the same edit produces `Compiling interface Widget`, `Compiling Widget.cpp`, `Compiling main.cpp` and a pass. ## The change - `Module` gains `moduleDependencies`, `externalModuleDependencies` and `pendingImports`, with the same meanings they already have on `ModulePartition`. - Primary interface units stay in the scan list so their `import` lines land on those vectors. All primaries are registered before any import is resolved, so declaration order doesn't matter (a module may import a sibling declared after it). - `Module::Check` sees through the new edges — recursively for a local sibling, by PCM mtime for a dependency's module. - `Module::Compile` orders itself behind a local sibling it imports. - `ResolvePendingImports` sweeps module-level pending names, so the edge survives `cfg.dependencies` being assigned after the scan. - `Build()` now `Check`s every interface **before** spawning any compile thread. The `compiled` flag a waiter blocks on is raised either by a `Compile` that runs or by the `Check` that decides none is needed, so a `Check` still pending while another module's thread was already waiting would have hung the build outright. - `needsRecompiling` gets a default initializer (it was read-before-write on a dependency cycle). ## Verification - New `tests/TransitiveInterfaceChange`: asserts the scan records a primary unit's imports and that pending/resolve reaches them; asserts a sibling import resolves locally even when declared first, and clean-builds four times over to pin the ordering; then end-to-end grows `Payload` and asserts the importing interface unit, that module's implementation unit *and* the consumer's TU are all recompiled and the binary agrees on the layout. Against the unfixed library it reports 11 failed assertions. - `crafter-build test`: **19 passed**, including the pre-existing 18. - `crafter-build lint`: clean, 42 files, 15 rules. - Self-host builds three times from scratch, and the stage-2 binary runs the suite green (19 passed) — the reordered interface pass doesn't deadlock on a real 1-primary/10-partition project. Resolves #26 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix: track what a primary module interface imports
Some checks failed
CI / build-test-release (pull_request) Failing after 6m5s
f38521298b
A primary module interface unit — `export module Widget;`, no partitions —
recorded nothing about what it imported. GetInterfacesAndImplementations
registered the Module and then erased the file from the scan list, so the
import pass only ever saw partitions, and Module had no vectors to hold an
edge anyway.

Two consequences, both reported as issue #26:

  Module::Check consulted only its own .cppm and its partitions. A data
  member added to an imported module left Widget.pcm, Widget.o and every
  consumer object untouched while the imported library rebuilt and both
  binaries relinked — one executable holding two class layouts, no
  diagnostic, and a crash somewhere unrelated. Wiping build/ was the only
  cure, so `crafter-build test` could not be trusted straight after an
  interface edit.

  Module::Compile waited on nothing. Two modules in one Configuration
  compile on concurrent threads, so a primary interface importing a
  sibling was a coin flip between working and "module 'Base' not found".

Partitions never had either problem — they carry the same three vectors and
Check/Compile honour them — which is why the gap only surfaced on a module
whose interface is one flat unit.

Module now carries moduleDependencies, externalModuleDependencies and
pendingImports with the same meanings as on ModulePartition; primary units
stay in the scan list so their imports land there; Check sees through them;
Compile orders itself behind a local sibling; and ResolvePendingImports
sweeps them so an edge survives dependencies being wired up afterwards.
Build() now Checks every interface before spawning any compile thread — the
`compiled` flag a waiter blocks on is raised either by a Compile that runs
or by the Check that decides none is needed, so a Check still pending while
another module's thread waits would have hung the build.

Resolves #26

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
catbot merged commit ded60bb8c3 into master 2026-07-30 18:20:43 +00:00
catbot deleted branch claude/issue-26 2026-07-30 18:20:43 +00:00
Sign in to join this conversation.
No description provided.