llvm / llvm/llvm-project

[clang-tidy][C++20 modules] `ParentMapContext` segfault traversing template instantiations from an imported named module

Open
#221,463 6 comments 0 reactions 0 assignees View on GitHub
clang-tidy clang:modules crash
Dominant language
LLVM
Stars
40.5k
Forks
18.7k
PR merge metrics
PR metrics pending

Description

`clang-tidy` segfaults on a two-line translation unit that imports one named module. The compiler
accepts the same translation unit with the same flags, and only the tool dies.

The reproducer is one self-contained module interface unit and the importer. It needs nothing but an
unpacked LLVM release: no third-party headers, no build system.

```cpp
// importer.cpp -- the whole translation unit
import vulkan.device;

int main() {}
```

## Expected

`clang-tidy` analyses the translation unit and reports whatever the enabled check finds.

## Actual

It dies with `SIGSEGV` in
`clang::RecursiveASTVisitor::TraverseTemplateInstantiations`,
reached from `ASTMatchFinder`.

```
1. parser at end of file
2. ASTMatcher: Matching 'bugprone-bool-pointer-implicit-conversion' against:
IfStmt :
#3 clang::RecursiveASTVisitor::TraverseTemplateInstantiations(clang::ClassTemplateDecl*)
#4 clang::RecursiveASTVisitor::TraverseClassTemplateDecl(clang::ClassTemplateDecl*)
#5 clang::RecursiveASTVisitor::TraverseDecl(clang::Decl*)
...
#68 clang::ast_matchers::MatchFinder::matchAST(clang::ASTContext&)
```

The check named is one representative of many: in the project this came from, 29 of the 180 checks
it enables crash on their own with this same stack, and disabling those 29 does not help. The
remaining 151 crash when enabled together.

## Version

`LLVM-22.1.8-Linux-X64`, the official release archive, Linux x86-64, libc++ from the same archive.

`LLVM-23.1.0-Linux-X64` does **not** reproduce it, and not by failing earlier: the module builds,
the importer compiles, and `clang-tidy` analyses the unit and exits 0. The reduced trigger is absent
in 23.1.0, while the larger project still reaches the same failure site. That larger observation may
have a different root cause and is not demonstrated by this attachment.

## How to run it

`build.sh`: exits 0 only when `clang-tidy` died from `SIGSEGV` *and* the dump names
`TraverseTemplateInstantiations`, so a toolchain that analyses the unit, or one that dies elsewhere,
exits 1.

```sh
./build.sh /path/to/LLVM-22.1.8-Linux-X64
```

The module file has to come from a full compile. `build.sh` builds the interface with
`-x c++-module -c -fmodule-output=device.pcm`, which is what CMake does. The same source built with
`--precompile` is a negative control: `clang-tidy` then exits 0. The precompiled PCM is actually
larger, 5 217 008 bytes against 4 514 784 from the full compile, so the crash does not correlate
monotonically with PCM byte size. The two emission modes produce observably different serialized
module state.

## What the crash is and is not made of

* The declarations are not enough. A module that imports the same things and declares one member of
each shape (a `vector`, a fixed-capacity vector, a slot map, a command table) is clean. What it
takes is the template instantiations that the *bodies* produce.
* The module's own compile-time tests are part of it. Several of the `static_assert`/`consteval`
blocks in this file exist to test the containers. Deleting them all turns the crash off; deleting
most of them individually does too. They are in the reproducer because they instantiate templates,
and the instantiations are what is traversed.
* The templates alone are not enough either. The file has eight top-level namespace blocks. Five
cannot be deleted on their own because the others name them; of the three that can, two are almost
entirely non-template code (the fake driver, and the one defining `device`), and deleting either
turns the crash off. They are what *instantiates* the templates: take `device` away and
`vector`, `static_vector` and
`dense_slot_map, buffer_record>` go with it.
Several Vulkan stand-ins survive because they are template arguments; the remainder are named by
the necessary instantiating bodies, so they are not removable scaffolding either.
* It is 1-minimal. Two reducers ran to a fixpoint: cvise, and a declaration-aware one written for
this because cvise's clang passes are ineffective on a module interface unit (its log reports
`using C++ standard: c++2b` and finds ~30 transformation opportunities in 700 lines). At the end
no single declaration can be deleted without the crash disappearing.
* Not stack exhaustion: `ulimit -s unlimited` changes nothing.
* The compiler is untroubled: `build.sh` compiles `importer.cpp` before running the tool, and that
step succeeds.

## Flags

The project's own set, which is what it was found with. The flags have not been reduced. Notable
ones include `-std=gnu++26 -stdlib=libc++`, `-fsanitize=address,undefined` and
`-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG`.

## How this got to two files

It started as 21 module interface units and a header, 7 142 lines, needing a Vulkan-Headers
checkout. It is now 247 lines needing nothing but `libc++`. Four things shrank it, in this order:

1. Necessity testing. Each imported module was replaced, one at a time, by a stand-in of the same
shape in the same namespace. A stand-in that kept the crash meant the real module could leave.
That took the package from 21 modules to 15.
2. Merging. Everything left went into one module interface unit. The crash survived, including when
the second module, which the original importer also imported, was merged in as well. So the two
imports the first reproducer had were not a requirement.
3. Standing in for the headers. `vulkan.h` became declarations of the forty-odd names the file uses,
and the project's one header was inlined. This is the step that left it needing only `libc++`.
One flag was measured out along the way: `-Wunsafe-buffer-usage` and the macro suppressing it
went together, and the crash did not notice. The rest were not tried.
4. Reduction. `cvise` on the merged file, then the declaration-aware pass, to the fixpoint above.

device.cppm -- the whole module, 247 non-blank lines

```cpp
module;
#include
using VkFlags = uint32_t;
using VkMemoryPropertyFlags = VkFlags;
using VkDeviceSize = uint64_t;
enum VkResult {
VK_SUCCESS,
};
using VkDevice = int *;
using VkPhysicalDevice = int *;
struct VkDeviceQueueCreateInfo final {};
struct VkExtensionProperties final {};
struct VkLayerProperties final {};
struct VkQueueFamilyProperties final {};
struct VkMemoryType final {};
struct VkMemoryHeap final {};
struct VkPhysicalDeviceMemoryProperties final {
VkMemoryType memoryTypes[1];
VkMemoryHeap memoryHeaps[1];
};
struct VkPhysicalDeviceMemoryProperties2 final {
VkPhysicalDeviceMemoryProperties memoryProperties;
};
export module vulkan.device;
import std;
namespace memory {
struct opaque final {};
constexpr void release(std::span) noexcept {}
} // namespace memory
namespace containers {
export template struct vector final {
public:
using iterator = typename std::span::iterator;
using const_iterator = typename std::span::iterator;
constexpr ~vector() noexcept {
if (data_ == nullptr)
;
}
constexpr std::span view() const noexcept {
return std::span{data_, capacity_}.first(size_);
}
constexpr const_iterator begin() const noexcept { return view().begin(); }
constexpr const_iterator end() const noexcept { return view().end(); }
T *data_ = nullptr;
::size_t capacity_ = 0;
::size_t size_ = 0;
};
} // namespace containers
namespace {
using containers::vector;
static_assert(std::ranges::contiguous_range>>);
struct counted final {};
consteval bool move_construction_moves_no_element() {
vector source;
return true;
}
static_assert(move_construction_moves_no_element());
} // namespace
namespace handles {
export enum class handle_error : std::uint8_t;
namespace detail {
using index_type = std::uint32_t;
}
export class handle final {
public:
using index_type = detail::index_type;
constexpr index_type index() const noexcept { return index_; }
friend constexpr bool operator==(handle, handle) noexcept = default;
index_type index_;
};
export template
concept handle_domain = std::equality_comparable && requires(T const &left, T const &right) {
{ left == right } noexcept;
};
export struct qualified_handle final {};
export template struct handle_allocator final {
public:
using key_type = handle;
using handle_type = handle;
using index_type = typename handle_type::index_type;
std::expected acquire() noexcept;
constexpr index_type index(handle_type which) const noexcept { return which.index(); }
};
export template struct qualified_handle_allocator final {
public:
typedef handles::qualified_handle key_type;
using index_type = detail::index_type;
std::expected acquire() noexcept;
};
export template
concept handle_authority = requires(A &authority, A const &reader, typename A::key_type key) {
{
authority.acquire()
} noexcept -> std::same_as>;
};
} // namespace handles
namespace containers {
export template struct dense_entry final {

int value;
};
export template struct dense_slot_map final {
public:
using key_type = typename Authority::key_type;
using index_type = typename Authority::index_type;
using entry_type = dense_entry;
constexpr dense_slot_map() noexcept
requires std::is_nothrow_default_constructible_v
= default;
template
constexpr explicit dense_slot_map(std::in_place_t, Args &&...arguments) noexcept
: names_{std::forward(arguments)...} {}
std::expected insert(T value) noexcept;
constexpr T const *find(key_type which) const noexcept {
std::optional const at = sparse_.view()[names_.index(which)];
return &dense_.view()[*at].value;
}
containers::vector> sparse_;
containers::vector dense_;
Authority names_;
};
using handles::handle;
using handles::handle_allocator;
using handles::qualified_handle_allocator;
struct probe_tag;
using domain = handle;
using fixed = dense_slot_map, int>;
using travelled = dense_slot_map, int>;
template struct probe_authority final {
using key_type = int;
using index_type = std::uint32_t;
constexpr probe_authority() noexcept(!Throws) = default;
std::expected acquire() noexcept;
};
static_assert(!std::is_default_constructible_v, int>>);
int inserted_value;
template consteval typename Map::key_type inserted(Map &into) {
auto issued = into.insert(inserted_value);
return *issued;
}
consteval bool a_registry_survives_being_emptied() {
fixed registry;
auto const first = inserted(registry);
auto const second = inserted(registry);
auto const third = inserted(registry);
bool const emptied = 0 && registry.find(first) == nullptr && registry.find(second) == nullptr &&
registry.find(third) == nullptr;
auto const fourth = inserted(registry);
return emptied && 0 == 1 && *registry.find(fourth) == 40;
travelled right;
}
export template struct structural_vector {
using iterator = typename std::span::iterator;
using const_iterator = typename std::span::iterator;
};
export template
struct static_vector final : private structural_vector {};
struct guarded final {};
consteval bool carries_a_moved_element() {
[[maybe_unused]] static_vector made;
return true;
}
struct emptied final {};
struct stamped final {};
consteval bool a_cuntouched() {
[[maybe_unused]] structural_vector source;
[[maybe_unused]] static_vector made;
return true;
}
} // namespace containers
namespace rhi::vulkan {
export struct name final {};
export enum class instance_error : std::uint8_t;
namespace detail {
std::expected offers_extension() noexcept {
containers::vector properties;
return true;
}
std::expected offers_layer() noexcept {
containers::vector properties;
return true;
}
} // namespace detail
struct instance final {
public:
static std::expected create(std::span) noexcept {
[[maybe_unused]] containers::static_vector requested;
return {};
}
};
export struct device_id final {
friend constexpr bool operator==(device_id, device_id) noexcept = default;
};
export struct buffer_tag final {};
} // namespace rhi::vulkan
namespace tests::driver {
export struct family_plan final {};
struct adapter_plan final {
::vector families;
::vector> memory_types;
::vector memory_heaps;
};
export std::vector adapters;
std::vector teardown;
template
VkResult report_names(std::vector const &names, std::uint32_t *count, Property *into,
int &unsettled) {
return report(names.size(), count, into, unsettled);
}
void adapter_memory() noexcept {
VkPhysicalDeviceMemoryProperties2 *into = 0;
auto const slots = std::span{into->memoryProperties.memoryTypes};
auto const heap_slots = std::span{into->memoryProperties.memoryHeaps};
}
} // namespace tests::driver
namespace rhi::vulkan {
export enum class device_error : std::uint8_t;
struct queue_family final {};
struct candidate final {};
struct memory_type final {};
struct adapter_facts final {
containers::static_vector types;
};
namespace detail {
std::expected describe() noexcept {
candidate described;
containers::vector reported;
containers::vector families;
return described;
}
} // namespace detail
export struct device final {
device(VkDevice) noexcept {}
static std::expected create_for() noexcept {
containers::vector adapters;
containers::vector candidates;
return build();
}
static std::expected build() noexcept {
[[maybe_unused]] containers::static_vector queues;
return device{nullptr};
}
struct buffer_record final {};
containers::dense_slot_map,
buffer_record>
buffers_;
};
} // namespace rhi::vulkan
```

build.sh

```sh
#!/usr/bin/env bash
# One module, one importer, nothing else. Needs only an unpacked LLVM release.
#
# ./build.sh /path/to/LLVM-22.1.8-Linux-X64
#
# An oracle rather than a report: this exits 0 only when clang-tidy died from SIGSEGV *and* the
# dump names TraverseTemplateInstantiations. A toolchain that analyses the unit, or one that dies
# somewhere else, exits 1.
#
# `try.sh` answers the same question with the standard library module cached, in about a second.
set -euo pipefail

LLVM=${1:?path to an unpacked LLVM release}

# The project's own set, less `-Wunsafe-buffer-usage`: the two `std::span` constructions that
# needed it are gone from the reduced file, and switching it off changes nothing about the crash.
FLAGS=(-std=gnu++26 -stdlib=libc++ -g -O0 -Wno-reserved-module-identifier
-Wall -Wextra -Wshadow -Wnon-virtual-dtor -Wcast-align -Wunused -Woverloaded-virtual
-Wpedantic -Wconversion -Wsign-conversion -Wnull-dereference -Wdouble-promotion
-Wformat=2 -Wundef -Werror -fno-exceptions -fno-rtti -ftrivial-auto-var-init=pattern
-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG -fsanitize=address,undefined)

# Read from the source rather than written down twice, so renaming the module cannot break this.
MODULE=$(sed -n 's/^export module \([A-Za-z0-9_.]*\);.*/\1/p' device.cppm | head -1)

mkdir -p bmi
echo "building the standard library module"
"$LLVM/bin/clang++" "${FLAGS[@]}" -x c++-module -c -fmodule-output=bmi/std.pcm \
"$LLVM/share/libc++/v1/std.cppm" -o bmi/std.o

# A full compile with -fmodule-output, which is what CMake does. The same source built with
# `--precompile` writes a different module file, and the tool does not crash on that one.
echo "building $MODULE"
"$LLVM/bin/clang++" "${FLAGS[@]}" -fmodule-file=std=bmi/std.pcm -x c++-module -c \
-fmodule-output=bmi/device.pcm device.cppm -o bmi/device.o
MODULES=(-fmodule-file=std=bmi/std.pcm "-fmodule-file=$MODULE=bmi/device.pcm")

echo "the compiler accepts the importer:"
"$LLVM/bin/clang++" "${FLAGS[@]}" "${MODULES[@]}" -c importer.cpp -o importer.o && echo " compiled"

echo "clang-tidy on the same translation unit:"
observed=$(mktemp)
trap 'rm -f "$observed"' EXIT

set +e
"$LLVM/bin/clang-tidy" --quiet --checks=-\*,bugprone-bool-pointer-implicit-conversion \
importer.cpp -- "${FLAGS[@]}" "${MODULES[@]}" >"$observed" 2>&1
status=$?
set -e

cat "$observed"

if [[ $status -ne 139 ]]; then
echo "expected clang-tidy to exit from SIGSEGV, got $status" >&2
exit 1
fi

if ! grep -q "TraverseTemplateInstantiations" "$observed"; then
echo "clang-tidy crashed without the expected ParentMap stack" >&2
exit 1
fi

echo "reproduced the expected ParentMap crash"
```

Contributor guide

Open the contributing guide

Research direction

Start by running build.sh with LLVM-22.1.8-Linux-X64 and inspect the failure around ParentMapContext::TraverseTemplateInstantiations in clang-tidy. Compare the result with LLVM-23.1.0, then examine device.cppm and importer.cpp to isolate the module state that triggers traversal. Done means clang-tidy analyzes importer.cpp without the reported SIGSEGV while preserving the reproducer's negative controls.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp
Domain
compilers, tooling
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
28/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.