Things a report from an editor pointed at, that are not fixed — with the measurement that says where they stand, so the next run starts from evidence rather than from the report again.
Not reproduced. Both halves of the question are now tests, and they answer the opposite of the report.
-
Resolution works through the path the server takes. In a real MSVC 18 project —
#include <iostream>andstd::cout << std::endl;— asking for every project file's reading and letting the pump refine the closure answers the declaration:$ cargo test -p cpp_code_analysis --test std_visibility_probe std_endl_resolves -- --ignored test result: okThat is
std_endl_resolves_through_the_servers_path, an ignored probe: it needs the machine's own standard library and costs about six seconds, so it is run on demand rather than with the suite. The fact is afunctionin scopestd, declared at__msvc_ostream.hpp:1007— MSVC 18 movedendlone hop past<ostream>, which is what made this look like a visibility problem. -
The
NotDeclaredHerethis file used to record was an artefact ofinspect.inspectasks for one file's reading; a header two hops out then has no summary, so the visibility walk enters it and finds nothing to report. Measured on a three-file fixture (c.cpp→b.h→a.h):inspectanswersNotDeclaredHere, while the same chain through the session's path answers the declaration —reported_defects::a_declaration_two_includes_away_is_visible. -
The classifier was never the problem. A namespace-scope function named by a qualified expression is drawn as
Function/Method, never as a value —a_qualified_function_is_drawn_as_a_function.
So what would produce the report in a project where the name resolves is either a fact the classifier's claimed
set already holds (_-prefixed predefined macros, module declarations) or a region the cook dropped. Neither is
reproducible here. The file it happened in is needed, with the line.
Reported with a screenshot, and five fixtures of the reported shape all printed std::string, so the entry ended
with "a file and a line number are needed, not another guess". The line arrived: LuaCodeFormat.cpp:131,
auto formattedText = f.GetFormatResult(t); where RangeFormatBuilder::GetFormatResult returns std::string.
Reproduced over stdio (cpp_ls/tests/real_project_hover_probe.rs): both the hover and the inlay hint said
basic_string<char, char_traits<char>, allocator<char>> — and kept saying it for ninety seconds. A session built by
the crate's own probe answered std::string about the same offset, which is why five fixtures missed it.
The difference is the state of the index, and the step is finish_a_nested_name. It exists to make a member's
spelling usable — pointer becomes _Val* — and it treated std::string as "the member string of the class std",
looked that member up, and replaced the spelling with the member's type: the alias's target. It only does that once
the index holds the using string = basic_string<…> declaration, which is why an early request answered correctly by
accident and a warm one did not.
The rule now: a name qualified by a namespace is not a member of a class, and is left as written
(names_a_namespace, used by finish_a_nested_name and by a_spelling_through_the_aliases_in). Measured after the
change, over the same stdio probe:
hover at 130:12 std::string formattedText
inlay at 130:22 ": std::string"
tests/types.rs' a_deduced_type_keeps_the_alias_the_source_wrote is the unit-level statement; it failed with the
expanded spelling before the guard and passes after.
A question asked by inspect is not always the question the server answers: it asks for one file's reading, so any
cross-file answer that depends on the closure — two hops of #include, a module's interface, a header behind a
#include a cook never read — can be wrong there while being right in the editor. The two fixtures above are the
minimal reproduction of that difference, and they are tests, so the next person to be misled by inspect can see it
in one run rather than in six rounds.
crates/cpp_code_analysis/tests/diagnostics_sweep_probe.rs pulls every file's diagnostics in one process and prints
the findings grouped by code, with file:line and the message — the sweep a false-positive review needs. Run it with
--ignored, and CPPLS_SWEEP_FILES=<n> to look at a slice:
cargo test --release -p cpp_code_analysis --test diagnostics_sweep_probe -- --ignored --nocapture
Measured on EmmyLuaCodeStyle, the first 142 files (its 3rd/asio-1.24.0 included):
before 32 findings: macro-parentheses 27, missing-include 5
after 1 finding: missing-include 1
The two classes it found, and the fix each got:
macro-parentheseson macros that expand to a type.typename ::asio::detail::async_result_helper<ct, sig>has a<and a>, and the scanner read the first as a binary operator. The scanner now recognises a template-id (template_id_end: names, qualified names, numbers, nested template-ids,*,&,,and spaces, and it closes), and a body headed bytypenameis a type fragment. A type has no precedence to get wrong — a caller's expression never reaches it.missing-includenaming a source file. Four asio headers were told to includegtest_unittest.cc, because a template parameter namedTis also a type fact in a test file. A.cc,.cor.cppis compiled rather than included, so its declarations can never be made visible by naming it — the rule now skips a candidate that is a source file.
After the fixes: the same slice has no rule findings at all, and so do the first 380 files of the project
(16 parse errors, which are ignored here). The one finding that survived the first pass —
associated_executor.hpp:155's is_convertible, "declared" in an unrelated header — was a dependent template-id: the
name is used inside a template argument list of a template declaration, where it may be a dependent base's, an ADL-only
name, or one the instantiation supplies. The rule reads declaration spellings and has no scopes for the use, so it now
skips a name used with template arguments inside a template — while a plain type in a template's parameter list is still
reported.
What that does not measure is false negatives: a rule that is quiet is not a rule that is right, and this pass only asked "did any rule say something that is not so". A negative sweep — known-good findings that must fire — is the next instrument, and the 139 check tests are what stands in for it today.
Not reproduced. Both halves of the question are now tests, and they answer the opposite of the report.
-
Resolution works through the path the server takes. In a real MSVC 18 project —
#include <iostream>andstd::cout << std::endl;— asking for every project file's reading and letting the pump refine the closure answers the declaration:$ cargo test -p cpp_code_analysis --test std_visibility_probe std_endl_resolves -- --ignored test result: okThat is
std_endl_resolves_through_the_servers_path, an ignored probe: it needs the machine's own standard library and costs about six seconds, so it is run on demand rather than with the suite. The fact is afunctionin scopestd, declared at__msvc_ostream.hpp:1007— MSVC 18 movedendlone hop past<ostream>, which is what made this look like a visibility problem. -
The
NotDeclaredHerethis file used to record was an artefact ofinspect.inspectasks for one file's reading; a header two hops out then has no summary, so the visibility walk enters it and finds nothing to report. Measured on a three-file fixture (c.cpp→b.h→a.h):inspectanswersNotDeclaredHere, while the same chain through the session's path answers the declaration —reported_defects::a_declaration_two_includes_away_is_visible. -
The classifier was never the problem. A namespace-scope function named by a qualified expression is drawn as
Function/Method, never as a value —a_qualified_function_is_drawn_as_a_function.
So what would produce the report in a project where the name resolves is either a fact the classifier's claimed
set already holds (_-prefixed predefined macros, module declarations) or a region the cook dropped. Neither is
reproducible here. The file it happened in is needed, with the line.
Reported with a screenshot, and five fixtures of the reported shape all printed std::string, so the entry ended
with "a file and a line number are needed, not another guess". The line arrived: LuaCodeFormat.cpp:131,
auto formattedText = f.GetFormatResult(t); where RangeFormatBuilder::GetFormatResult returns std::string.
Reproduced over stdio (cpp_ls/tests/real_project_hover_probe.rs): both the hover and the inlay hint said
basic_string<char, char_traits<char>, allocator<char>> — and kept saying it for ninety seconds. A session built by
the crate's own probe answered std::string about the same offset, which is why five fixtures missed it.
The difference is the state of the index, and the step is finish_a_nested_name. It exists to make a member's
spelling usable — pointer becomes _Val* — and it treated std::string as "the member string of the class std",
looked that member up, and replaced the spelling with the member's type: the alias's target. It only does that once
the index holds the using string = basic_string<…> declaration, which is why an early request answered correctly by
accident and a warm one did not.
The rule now: a name qualified by a namespace is not a member of a class, and is left as written
(names_a_namespace, used by finish_a_nested_name and by a_spelling_through_the_aliases_in). Measured after the
change, over the same stdio probe:
hover at 130:12 std::string formattedText
inlay at 130:22 ": std::string"
tests/types.rs' a_deduced_type_keeps_the_alias_the_source_wrote is the unit-level statement; it failed with the
expanded spelling before the guard and passes after.
A question asked by inspect is not always the question the server answers: it asks for one file's reading, so any
cross-file answer that depends on the closure — two hops of #include, a module's interface, a header behind a
#include a cook never read — can be wrong there while being right in the editor. The two fixtures above are the
minimal reproduction of that difference, and they are tests, so the next person to be misled by inspect can see it
in one run rather than in six rounds.
crates/cpp_code_analysis/tests/diagnostics_sweep_probe.rs pulls every file's diagnostics in one process and prints
the findings grouped by code, with file:line and the message — the sweep a false-positive review needs. Run it with
--ignored, and CPPLS_SWEEP_FILES=<n> to look at a slice:
cargo test --release -p cpp_code_analysis --test diagnostics_sweep_probe -- --ignored --nocapture
Measured on EmmyLuaCodeStyle, the first 142 files (its 3rd/asio-1.24.0 included):
before 32 findings: macro-parentheses 27, missing-include 5
after 1 finding: missing-include 1
The two classes it found, and the fix each got:
macro-parentheseson macros that expand to a type.typename ::asio::detail::async_result_helper<ct, sig>has a<and a>, and the scanner read the first as a binary operator. The scanner now recognises a template-id (template_id_end: names, qualified names, numbers, nested template-ids,*,&,,and spaces, and it closes), and a body headed bytypenameis a type fragment. A type has no precedence to get wrong — a caller's expression never reaches it.missing-includenaming a source file. Four asio headers were told to includegtest_unittest.cc, because a template parameter namedTis also a type fact in a test file. A.cc,.cor.cppis compiled rather than included, so its declarations can never be made visible by naming it — the rule now skips a candidate that is a source file.
What is left (1 finding in the slice):
associated_executor.hpp:155 `is_convertible` is declared in buffers_iterator.hpp, which this file does not include
is_convertible is used inside a dependent template-argument list (constraint<is_convertible<…>::value>), and the
declaration the index offers for that spelling is a different entity in a header this file does not reach. The rule
works from declaration spellings and cannot resolve a name, so the honest next step is to skip a spelling that
appears inside a template argument list of a template declaration — a dependent name resolved at instantiation —
rather than to add a word list.
The failure was one guard in name_references:
// **A file that declares the spelling as a local is not searched.**
let declares_a_local = …any(|fact| fact.local && fact.name == name);
if declares_a_local { found.not_looked_at.push(path); continue; }a.cpp declares a local size, so the whole file was skipped — including the use of the global size in another
function — and a rename of int size = 0; answered 0 places. Measured in that order: definitions found the fact,
name_references answered 0 files, name_level_offsets answered 0 places. A file-level answer cannot be right for a
per-occurrence question.
The fix asks it per occurrence, where it has an answer: the scan no longer skips the file, and name_level_offsets
drops an occurrence when the file's own scopes resolve it to a binding a function body owns
(resolves_to_a_function_local: definition_at plus the scope chain). Measured on the fixture, the global answers
lines 0 and 2 — its declaration and the use in two, and not the shadowing local on line 1 — and
a_rename_stays_inside_the_scope_of_what_it_names pins both that and the local's own exact answer. On
EmmyLuaCodeStyle, GetState still answers 6 uses.
-
A member in a mem-initialiser is drawn as a function.
LuaCodeFormat::LuaCodeFormat() : _supportNonStandardSymbol(false)colours_supportNonStandardSymbolwithentity.name.function. It is a member, and the initialiser is not a call: the declaration's name in: name(args)has to be classified as a field even though the shape is call-like. Where to look:semantic_token's classification of a name inside a mem-initialiser list, andsema/scopes' binding for it — the same node a constructor'sFoo::Foois. -
Find References for
SpellChecklistsDiagnosticBuilder::SpellCheck. The class narrowing is the only attribution a member name has, and it is skipped whenthe_class_the_cursor_is_inreturnsNone— which is the case for a definition written asLuaCodeFormat::SpellCheck(...)if the declarator's class is not read from the text. The CodeLens for the same name answers 1 (correct), because it asks the fact's qualified name. The narrowing has to stop being "the class the cursor is in" and become "the class the declaration belongs to", which the fact already says. -
LuaCodeFormatsometimes drawn as a function, and an edit restores the type. Not reproducible by file content: it is the state of the index. The name is both a class and the constructorLuaCodeFormat::LuaCodeFormat, and whether the type or the constructor fact answers depends on which factkind_offinds first —kind_ofrequires the candidates to agree, so a disagreement must be falling back to one of them. The instrument is to print, at that offset, the candidate facts (every_fact_named("LuaCodeFormat")) with theirkind,scope,returns, and whatkind_of_indexanswers; the fix is probably in the fact for a qualified constructor, if the parser records its return type asLuaCodeFormat— then it is not a constructor by the "no return type" rule. -
Inlay hints and CodeLens disappear on every keystroke and come back seconds later. Answering nothing while the index catches up is worse than answering a number that grows: the client is told the answer changed, so it erases what it drew. The CodeLens now answers from what is known and lets
workspace/codeLens/refresh(sent when indexing drains) correct it; inlay hints should get the same treatment — never answer "empty because not ready", always answer the current best and refresh. -
std::vector<T>::emplace_back()'s result is unknown, soauto &result = results.emplace_back()has no type andresult.Startis unresolvable. Corrected by measurement (see the two sections below): the substitution the paragraph above guesses at is not what failed. In a fixture the same call answersItem&in all five spellings tried — alias, alias of alias, written-out_Ty&, template member, plain member. What actually answered line 304 was a range-for leak, and what answers it now is the other question,std::vectornot being resolvable.
LuaCodeFormat.cpp:304 — auto &result = results.emplace_back(); — is the first statement of
for (auto &diagnostic: diagnostics). range_for_element_type decided "is this declaration the loop's variable?"
by asking whether a RangeForStat is among its ancestors, which is true of every declaration in the body, and it
answered before the ordinary deduction ran. So result was typed as the loop's range, and the initializer was
never looked at. The type trace is one line long and says so:
[type_of] depth 0 "result"
[type_of] depth 2 "diagnostics" ← the range-for's range, not `results.emplace_back()`
The proof, in tests/emplace_probe.rs (an_auto_declaration_inside_a_range_for):
inside a range-for, `result` -> UnknownType("std::vector<Other>&") ← before
beside a range-for, `result` -> Item&
Two fixes, both with a regression test in tests/types.rs:
range_for_element_typenow answers only when the declaration is in the loop's head — before its:(is_in_the_head_of). A body declaration falls through todeduction_inputs, which is its own initializer.an_auto_declaration_in_a_range_for_body_is_deduced_from_its_own_initializerassertsItem&.range_element_ofnow unwrapsType::ReferenceandType::Qualifiedfirst.for (auto &x : parameter)over aconst std::vector<T>&— the ordinary spelling — typed the loop variable as the container.a_range_written_as_a_reference_still_binds_its_elementassertsint.
After both, the same line in the real project answers NotDeclaredHere("std::vector::emplace_back") instead of a
wrong type: the leak is gone, and what is left is the honest "this class is not in the index", which is the section
below.
One stale test was red in the tree, and the fix is the rule rather than the code.
generics.rs::a_nested_instantiation_has_its_members_and_its_argument asserted that auto row = grid.front(); is a
std::vector<int>&. It was written on 2026-10-18 (4ee66578), and read_the_way_auto_does — the rule that
auto x = f(); strips the top-level reference and const, which is what the language does — landed on 2026-11-01
(bb407dd3), so the test was red from then on and nothing had run it since. It now asserts both spellings: auto row
→ std::vector<int>, auto &bound → std::vector<int>&. The subject it was written for — the outer argument
substituted and the inner one kept — is asserted either way.
1c00d3ad ("a variable bound in a class scope is a property") made a class's field NameKind::Property, and updated
the handler's own tests. It did not update tests/semantic.rs, and it did not change what a client is sent when
its advertised tokenTypes have no property in them — so five tests were red from that day:
cpp_code_analysis tests/semantic.rs 4 failed expected Variable, got Property
cpp_ls handshake.rs:3518 1 failed the field `size`, over the wire
The first four are stale expectations: every one of them is about a class member (Widget::size) and Property is
the answer the analysis now gives on purpose. They assert Property with the reason written beside them.
The fifth was not stale — it is the end-to-end statement that a client receives a token it can draw. The client in
that test advertises namespace, type, typeParameter, enumMember, function, method, variable, parameter, macro — no
property — and Builder::push_range drops a kind the client cannot draw before claiming the span, so the field
was sent as property and drawn as nothing. The fix is the fallback the file already documents for
variableTemplate and for typename: a field a client cannot draw as a property is still a variable, and
property is standard but not universal. A client that lists it still gets it.
a_field_a_client_cannot_draw_as_a_property_is_drawn_as_a_variable pins both halves.
Reported with a screenshot: case TK_ELSEIF: in SpaceAnalyzer.cpp was drawn as variable.other.unknown — no
semantic token at all — and the reporter added that enum class names are missing at their uses too. The enumerator
is declared in LuaTokenTypeDetail.h, which that file includes directly.
Two rules were wrong, and both are about scope:
ProjectIndex::kind_ofkeeps a candidate whosescopeis set only when the qualified name is what was asked. That is right for a constructor (TextRange::TextRangeis not another meaning ofTextRange) and wrong for an enumerator: an unscoped enumeration's enumerators are visible in the enclosing scope, so the fact's scope is the enumeration's while the spelling asked is the bare one. Measured:kind_of("TK_ELSEIF", SpaceAnalyzer.cpp)answeredUnknown(ConditionalCompilation), and the use got zero classifications.kind_of_indexmatched "aVariablewith a scope is a property" before it askedenumerator, and an enumerator always has a scope, so that arm always won.
After both, over the same probe: kind_of → IndexedKind { kind: Variable, scope: Some("LuaTokenTypeDetail"), enumerator: true }, and the use has one classification, EnumMember. tests/semantic.rs'
an_enumerator_from_a_header_is_an_enum_member_at_its_use pins the unscoped and the enum class spelling, on the
plain view — the one the colouring pass is given, where a cross-file answer is the index's.
Reported with a screenshot: auto pos = _events.size(); in LuaParser.cpp:51 drawn as _Alty_traits::size_type.
Fixed after five measured steps, each one a defect of its own:
- a failed qualified name now asks for the file that would answer it (
ProjectIndex::ask_for_refinement, drained bySession::advance) —<xmemory>was never refined, sostd::allocator_traitsdid not exist; - a class template's base clause is the definition's, not its forward declaration's;
- a member's defaulted template argument is substituted (
allocator_traits<A>→allocator_traits<allocator<int>>); - a specialization's slot matches a qualified argument by head and recursively (
allocator<_Ty>againststd::allocator<MarkEvent>), and a trait's bool is the base its matching specialization inherits, which letsconditional_tpick one branch; - a class definition whose head writes a qualified name is a declaration again —
qualified_specifier_name_nodetookA<T>::Frominside the argument list ofstruct B<A<T>, void_t<typename A<T>::From>>for the declaration's own name, so the whole partial specialization was read as an out-of-line definition of a foreign class: no binding, no class scope, no members, no fact. That is what kept_Is_default_allocator<allocator<_Ty>, …>out of<xmemory>.FORMAT_VERSIONis now 18, because every cache built by the old reader holds the missing fact.
Measured over stdio against the real binary, on EmmyLuaCodeStyle:
[hover-probe] `pos` at 50:10 size_t pos (was `_Alty_traits::size_type`)
[hover-probe] `formattedText` std::string formattedText
[hover-probe] inlay hints ": std::string"
tests/types.rs::a_partial_specialization_is_a_declaration_with_a_pattern is the regression for (5), and
a_conditional_base_picks_the_branch_its_trait_selects for (3) and (4) together. Both were ignored reproducers while
the chain was open; both are ordinary passing tests now.
The survey that says how far the fix reaches. real_project_probe.rs::how_many_deduced_types_are_internal_spellings
asks every auto declaration in the project's own sources for its deduced type and counts the answers that are still
a library-internal spelling (_Alty_traits::size_type, _Ty, _Mybase::iterator — the signature of a chain that
stopped one step short):
[generics] 0 of 885 deduced type(s) are internal spellings
The one case that was internal before this work — LuaParser.cpp:51's pos, which answered _Alty_traits::size_type
— is what the five steps above fixed, so this number is the reach of the fix rather than a coincidence. It is the
probe to re-run after any change to the alias walk, the shape table, or the cache version.
One disagreement left, and it is about the harness rather than the answer. real_project_probe's
what_a_size_call_returns builds its own session, asks, pumps once and asks again — and on that flow both answers
are still _Alty_traits::size_type, while the server over stdio answers size_t pos. The difference is how many
drains the refinement passes get: the query's ask_for_refinement is served by the next idle round, and the class
behind size_type (std::allocator_traits) only answers correctly once <xmemory>'s and its own dependants have
been refined. The product surface is the server, and that is the measurement the objective named; the crate-level
probe needs to keep pumping until its answer settles rather than for exactly one round. Recorded here rather than
fixed, because a probe that waits is a one-line change and a probe that assumes the ordering is what misled this
investigation twice already.
Reported after the above, with a screenshot: auto pos = _events.size(); in LuaParser.cpp:51 drawn as
_Alty_traits::size_type. Reproduced over stdio with the same client (real_project_hover_probe.rs): the hover says
_Alty_traits::size_type pos.
The fixture that mirrors the shape (tests/types.rs, a_containers_size_called_on_an_object_is_its_size_type) answers
unsigned long long, so the fixture is not the shape the library has. What the trace of the real case shows
(CPPLS_TRACE_INFERENCE=1):
alias step: "_Alty_traits::size_type" in std::vector<MarkEvent> the return type
alias step: "_Alty_traits" in std::vector<MarkEvent>
alias step: "allocator_traits<_Alty>" in std::vector<MarkEvent> ← the target, with `_Alty` still unsubstituted
— and the walk stops here
_Alty_traits is declared in vector itself, not in a base (an earlier version of this note said a base, which
was wrong — <vector> lines 465-480):
using _Alty = _Rebind_alloc_t<_Alloc, _Ty>;
using _Alty_traits = allocator_traits<_Alty>;
using size_type = typename _Alty_traits::size_type;So the missing step is the substitution of _Alty inside allocator_traits<_Alty>: the walk resolves the
qualifier _Alty_traits to its target and then treats allocator_traits<_Alty> as a spelling to look up, where
_Alty is not a member of allocator_traits and nothing substitutes it. resolve_aliases is the reader that knows
how such an argument is written (it is what finish_a_nested_name uses for a base name), and it is not applied here.
The fixture in tests/types.rs (a_containers_size_called_on_an_object_is_its_size_type) mirrors that chain,
_Rebind_alloc_t in an included header — the shape the standard library has — and it passes. The trace of
the fixture and the trace of the real project differ at exactly one line:
fixture member of "allocator_traits<_Alty>": 1 listed, 0 unlisted, looking for "size_type"
→ the member resolved, then `_Alty` → `_Rebind_alloc_t<_Alloc, int>` → `unsigned long long`
real member of "allocator_traits<_Alty>": 0 listed, 1 unlisted, looking for "size_type"
→ no member resolved, so the walk answered the spelling it had
The first reason was <xmemory> never being refined: <vector> was (std::vector has three declarations) while
std::allocator_traits answered NotDeclaredHere. That is now fixed — ProjectIndex::ask_for_refinement is the
ask a failing qualified lookup leaves, Session::advance drains it into the refinement queue, and after one pump:
`std::allocator_traits`: 2 declaration(s) (was NotDeclaredHere)
`<xmemory>` refined: true (was false)
The remaining gap is one layer further in: allocator_traits now resolves but its member list is still
0 listed, 1 unlisted — the unlisted entry is the base that declares size_type, so the walk has a base it
cannot resolve rather than a class it cannot find. That is the next measurement: which base, and why.
Round 2 answered that. The base clause was not read at all at first, and that was a second defect:
the_base_clauses_of took the first Type fact for the name, which for a class template is the forward
declaration — xmemory:554 declares allocator_traits, 755 defines it with
: conditional_t<_Is_default_allocator<_Alloc>::value, _Default_allocator_traits<_Alloc>, _Normal_allocator_traits<_Alloc>> {}. The forward declaration has no bases, so the walk saw none. The preference is
now the fact's own definition flag — a definition is what the language calls the class.
After that, the gap is exactly one spelling:
unlisted: `conditional_t<_Is_default_allocator<_Alloc>::value, _Default_allocator_traits<_Alloc>,
_Normal_allocator_traits<_Alloc>>`
— NotDeclaredHere("conditional<_Test,_Ty1,_Ty2>::type")
The alias target of conditional_t was followed without substituting its arguments: the library's own parameter
names are still in the spelling (_Test,_Ty1,_Ty2) where the written arguments belong. That is the next fix, and it
is the shape this whole entry is about — an alias template whose target must be substituted before it is a class.
Round 3: the obvious fix is a trap, and the reproducer is now a test.
an_alias_template_used_as_a_base_substitutes_its_arguments (tests/types.rs) is a local fixture of the same
shape — an alias template whose target is typename pick<X, Y>::type, used as a base — and it fails today with
unlisted: ["pick_t<chosen<int>, other<int>>"], so the defect is reproducible without the standard library.
Calling a_spelling_through_the_aliases from the base walk to resolve it was tried and overflowed the stack:
that walk asks member questions, a member question builds a member list, and a member list walks bases. The fix
therefore has to be static — the alias fact's own parameters and target, read from the index, then the target member's
fact, substituted, bounded — and for conditional_t it has to stay sound where the condition cannot be evaluated:
the members both branches agree on, which is the rule the_return_the_overloads_agree_on already applies. The
test is #[ignore]d rather than deleted, and says all of that where the next reader will see it.
Round 4: the static resolver landed, and the member list went 0 → 44. the_classes_a_base_clause_names reads
facts only — an alias's parameters and type_of, then a class's member fact, substituted, bounded by
MAX_ALIAS_HOPS — and returns two classes when the base is conditional<Cond, A, B>::type, because the condition
is a library trait over a template parameter and choosing the primary template's _Ty1 would be a guess. Both are
walked and mark_ambiguity marks what they disagree about. an_alias_template_used_as_a_base_substitutes_its_arguments
is green and no longer ignored, and on EmmyLuaCodeStyle:
members_of("std::allocator_traits"): 44 listed, 0 unlisted (was 0 listed)
The next blocker is exact, and it is one feature:
size_type: type Some("size_t") ambiguous true std::_Default_allocator_traits<_Alloc>
size_type: type Some("typename _Get_size_type<_Alloc>::type") ambiguous true std::_Normal_allocator_traits<_Alloc>
allocator_traits<_Alloc> : conditional_t<_Is_default_allocator<_Alloc>::value, …> — deciding the branch means
evaluating _Is_default_allocator<std::allocator<MarkEvent>>, which is decided by matching a partial
specialization: template <class _Ty> struct _Is_default_allocator<allocator<_Ty>> : true_type {} against the
principal template's false_type. Selecting a partial specialization is the next piece of the generics work; until it
exists, size_type is honestly ambiguous and auto pos = _events.size(); keeps answering _Alty_traits::size_type.
Round 5 measured the shape of that piece, with a fixture that needs no standard library:
tests/types.rs::a_conditional_base_picks_the_branch_its_trait_selects (ignored, and it says what the answer must
be). It answers allocator_traits<A>::size_type today, and the spelling names three gaps in order:
- the defaulted template argument is not applied at the use —
std::vector<int>writes one argument,Adefaults toallocator<T>, andAis still standing whereallocator<int>belongs.substituted_spelling_with_defaultsexists for member types; the class level here passes only the written arguments. a_pattern_matchescompares text — the slotallocator<_Ty>against the argumentstd::allocator<MarkEvent>: the same class, spelled differently, so it does not match. Matching a template-id slot needs head names and a recursive comparison, not string equality.conditional<true, A, B>::typemust selectAonce the two above are decided —conditionalhas partial specializations fortrueandfalse, which is the same selection mechanism as (2).
Round 6 fixed (1): substituted_members cut the parameter list and the written arguments to the shorter side, so
a defaulted parameter was left standing rather than filled in. It now substitutes with the declaration's
defaults, and the trace shows allocator_traits<A> → allocator_traits<allocator<int>>.
Round 7 did (2): a_slot_matches matches a template-id slot by head (last name segment) and then its
arguments, recursively — allocator<_Ty> against std::allocator<MarkEvent> is the same class written with the
qualification the use needed, and text equality could never see it. The fixture still answers
allocator_traits<A>::size_type, so (3) is now the only blocker: the walk reaches
allocator_traits<allocator<int>> and asks it for size_type, whose two conditional branches declare it
differently — the member list is honest about the ambiguity and the trait has to be decided before a branch is.
That means evaluating _Is_default_allocator<std::allocator<int>>::value: match (2) selects the specialization, and
the answer is the true_type/false_type base it inherits. That is the next round.
Found while closing the size_type chain, and corrected twice, so the wrong readings are recorded here too:
- The first reading was "the parser truncates the class head at
typename". It does not:inspect treeprints a node's text cut at about sixty characters, and the cut landed on a token boundary. The tree forstruct B<A<T>, void_t<T>> : A<T> {}is complete,StructDefand all. - The second reading was "no partial specialization produces a fact". That was the fixture's fault: two declarations had been concatenated onto one line, so the second was never a declaration.
What the fact builder actually does, with inspect facts on a minimal file:
template <class T> struct A {};
template <class V> struct void_t {};
template <class T, class = void> struct B {};
template <class T> struct B<T, void_t<T>> : A<T> {}; → B type over [T] the specialization IS recorded
template <class T> struct B<T, int> : A<T> {}; → B type over [T] recorded
template <class T> struct B<A<T>, void_t<T>> : A<T> {}; → only the primary NOT recorded
So the trigger is narrow, and round 18 narrowed it further — the first reading ("first slot is a template-id")
is not it, because struct B<A<T>, A<T>> : A<T> { using inner = T; } does produce a fact (4:27 B, with its
member). The shapes measured with inspect facts:
B<T, void_t<T>> fact ✔
B<A<T>, A<T>> fact ✔
B<T, void_t<typename A<T>::From>> no fact ✘
B<A<T>, void_t<A<T>::From>> no fact ✘
B<A<T>, void_t<typename A<T>::From>> no fact ✘
What separates them is a qualified dependent name inside the specialization's argument list — A<T>::From, with
or without typename — while a bare A<T> in the same position is fine. Facts are built from bindings
(build_scopes → sema/declarations.rs), so the missing fact means the binding was never created, and the next step
is declared_name/name_node_along_declarator on this head shape.
_Is_default_allocator<allocator<_Ty>, void_t<typename allocator<_Ty>::_From_primary>> is the failing shape in the
standard library, and its absence is what keeps size_type unresolved.
template <class _Ty>
struct _Is_default_allocator<allocator<_Ty>, void_t<typename allocator<_Ty>::_From_primary>>
: is_same<typename allocator<_Ty>::_From_primary, allocator<_Ty>>::type {};— and the index holds no fact for it (17000 is the principal, 17304 is the next declaration: nothing between).
the_bool_a_trait_has therefore falls back to the principal's false_type, allocator_traits takes the
_Normal_allocator_traits branch, and size_type is typename _Get_size_type<_Alloc>::type where it should be
size_t. The parser is not at fault; the next step is the fact builder's class-declaration path, where the head
with a template-id in its first argument is not being turned into a fact.
This section replaces an earlier conclusion in docs/plan-three-fixes.md ("the initial pass builds summaries without
their closure's macros, so the refinement has to be taught to build them"). That conclusion was wrong about the
lever, and the measurements below are what showed it.
| asked by | answer about LuaCodeFormat.cpp:304 |
|---|---|
Session directly (real_project_probe.rs) |
result: Unknown(UnknownType("std::vector<LuaDiagnostic>&")) |
the real binary over stdio (real_project_hover_probe.rs, LSP textDocument/hover) |
the same string, as a hover card |
the same, on a copy of the project with no .cppls (546 summaries built cold) |
the same |
So the earlier finding was not an artefact of inspect (which cannot see includes), nor of the disk cache.
- The summary of
<vector>has no scope at all.993 fact(s), 0 with a std scope, namespace fact(s): [], and eachvectorfact isscope: None.macro_readings: 0— the plain reading never saw_STD_BEGINas a macro. - That is by design, and the scoped reading lives elsewhere. A summary is the shape-only reading
(
docs/indexing-design.md§2.1); the reading with bodies in force is the cooked one, built by the second pass. - The second pass is demand-driven, and nobody demands
<vector>.Session::advance'sfinish_a_slicetakestake_the_files_wanted_for_refinement(RE_READ_SLICE)— files somebody calledwant_refinementfor — andcatch_up(path)asks for the file a request is about, never for the header that declares what it names. Traced:re-read: eager false, 216 source(s), 175 word(s) mentioned, 2 candidate file(s), uncovered true, nothing bodied— one pass, for the.cpp, and<vector>is not in it.
real_project_probe.rs::does_refining_the_header_itself_scope_it indexes <vector> as a root, which is the one
thing that makes somebody ask for it:
re-read: … 1153 word(s) mentioned, 12 candidate file(s), 12 bodied name(s) — 329 ms
re-read: …\include\vector through a walk of its own
[header-probe] std::vector -> …\include\vector (kind Type, scope Some("std"), definition true)
[header-probe] std::vector -> …\include\vector (kind Type, scope Some("std"), definition false)
[header-probe] std::vector -> …\include\vector (kind Type, scope Some("std"), definition true)
The class resolves, at scope: "std", with its definition — after one refinement pass that was asked for. The
summary stays shape-only (0 with a std scope); the scope is in the cooked layer, which is what a lookup prefers.
Session::want_refinement is &self and cheap (a set insert behind its own lock), and take_the_files_wanted_for_ refinement serves it in the next idle round. What is missing is a caller: when a lookup answers NotDeclaredHere
for a qualified name, the files that declare its last segment are exactly the files worth asking about, and
ProjectIndex::every_fact_named already answers which they are. Two constraints on that change:
- it must not parse the type spelling of the refusal (
UnknownType("std::vector<…>")) to find the name — the name is known where the lookup happens, and a rule that reads a type back out of a message is the string-matching shape this crate removed once already; - the first answer after the ask is still the shape-only one. A request that arrives before the refinement round has
run answers
Unknownand the next one answers; that is the same contractis_owed_a_re_readalready states for a check, and it is whyhandshake.rsre-asks.
crates/cpp_code_analysis/src/session/tests.rs:3777,
a_class_scoped_by_another_files_macro_body_is_scoped_through_the_session, is #[ignore]d for this exact shape
(_STD_BEGIN defined in open.h, used in widget.h). Its reason cites docs/buglist.md §33 and
docs/indexing-review.md §6 — both files were deleted (3d954caf "clean doc", de501b6f "clean docs"), and the
recovered buglist.md has no §33; the closest record is §4, "the macro environment a written view wants is never
built by the pump". A citation to a deleted document is not a record, and the ignore reason should either be
re-pointed at a live document or the test un-ignored once the ask exists.
Reported with a screenshot of CodeFormatCLib.cpp: hovering drew a popup holding thirteen-odd identical lines
std::size_t EndLine and a scrollbar — a "very mysterious overload" about a data member. The file is four functions
over FormatRange, whose EndLine is one std::size_t in CodeFormatCore/Format/Types.h.
Every name on the reported screen was asked of the real server over stdio, then every byte offset in the file
(1 600-odd hovers, 133 distinct answers): no answer held the popup, because a freshly started server answered one
declaration. A Session opened with add_project_files answered the same — 6 declarations in FormatRange, one
named EndLine.
The difference is did_open. With the file opened the way an editor opens it, the same session answered
108 declarations — 18 named EndLine — all at the same ranges in types.h, and members_of returned 108
members; member_markdown prints one line per candidate and does not de-duplicate, which is the popup exactly. The
copies were not duplicate postings: the scope's posting list held 18 distinct slots, 25 declarations apart, so one
reading of the file contained eighteen copies of the same 25 declarations.
The rendering of that translation unit said the same thing: struct FormatRange appeared 18 times in
RenderedUnit::text, types.h was a file entry 20 times, and the stream was 919 228 tokens and 1 404 file
entries — against 742 907 tokens, 206 entries and one copy from the same project with an empty cache.
SummaryGuards::visit_once is what the walk reads to decide that a second #include must not re-enter a file. It was
recorded on a unit's frame (codec version 24) and on the summary in memory — and the file summary's encoder never
wrote it, so every summary decoded from .cppls said false:
stored summary: SummaryGuards { regions: [], conditionals: [], own_guard: None, visit_once: false }
fresh summary: SummaryGuards { regions: [], conditionals: [], own_guard: None, visit_once: true }
A cold start therefore entered #pragma once headers once, and a warm start re-entered each at every inclusion.
EmmyLuaCodeStyle's headers all begin with #pragma once, so one header included through eighteen paths declared
its six members eighteen times, and the member list — and the hover — showed it.
summary_codecwrites and readsvisit_once,CODEC_VERSION38 → 39, and theevery_fieldfixture now sets it totrueso the round-trip test fails if the encoder stops writing it.READING_FINGERPRINTfoldsCODEC_VERSION, so a record whose shape changed also moves the key of everything derived from it — a rendering, a unit's facts, a cooked reading, a walk contribution. Measured before this: a rendering kept answering after the summary reader was fixed, because nothing about the closure's texts had moved.cache::a_rendering_keyandwalk_cache::keyfoldREADING_FINGERPRINTfor the same reason at their own layer: a rendering and a walk contribution are functions of the reader as well as of the bytes.- The unit's per-file merge files a declaration by where it is written, not by inclusion:
(range, name_range)is the identity, so a stream that holds one header twice declares its members once. Regression test:translation_tests::a_header_the_stream_holds_twice_declares_its_member_once(it fails with2classes against the old concatenation).
[guard] SummaryGuards { …, visit_once: true } (twice: written, then read back)
[render] 742 907 token(s) of text, 206 file entr(ies)
[render] `struct FormatRange` appears 1 time(s); Types.h is a file entry 2 time(s)
[dup] declarations_in(FormatRange): 6 declaration(s) … members_of(FormatRange): 6 member(s)
[clib-probe] 17:10 `EndLine`: "std::size_t EndLine — FormatRange::EndLine — a variable of type std::size_t, in FormatRange"
The probes that carry these numbers are real_project_probe.rs::what_guard_the_cached_summary_records,
…::does_the_cached_rendering_hold_the_header_eighteen_times and …::where_the_duplicate_declarations_come_from.
Reported with two screenshots: missing-include on FunctionSingleArgSpace in
CodeFormatCore/include/CodeFormatCore/Config/LuaStyleStruct.h, and dangling-field on spell::Word's
std::string_view Item in CodeFormatCore/include/CodeFormatCore/Diagnostic/Spell/Util.h.
Every check over the project's own 198 files produced four findings from these two rules, and all four were wrong:
missing-include LuaStyleStruct.h `FunctionSingleArgSpace` is declared in luastyleenum.h, which this file does not include
missing-include CodeFormatLib/src/Types.h `DiagnosticType` is declared in diagnostictype.h, which this file does not include
dangling-field Spell/Util.h `Item` is a `string_view` initialised from `item`, a parameter that is destroyed …
dangling-field Util/include/Util/format.h `Expr` is a `string_view` initialised from `expr`, a parameter that is destroyed …
A header is not compiled on its own: its text is spliced into whichever translation unit includes it, and that file owns
the include order. LuaStyleStruct.h uses FunctionSingleArgSpace and includes neither it nor LuaStyleEnum.h — and
the project compiles because LuaStyle.h includes the enum on line 4 and the struct on line 5. CodeFormatLib/src/Types.h
is the same shape: LuaCodeFormat.h includes the diagnostic headers before it. The rule's premise ("the program compiles
only because another translation unit's include order happened to pull that header in first") is a statement about a
translation unit, and a header is not one.
Word(TextRange range, std::string_view item) : Range(range), Item(item) {} copies a view: the parameter object dies
at the closing brace, and the characters it names are the caller's. The rule treated every by-value parameter as a copy
that dies with its storage — which is true of S(std::string s) : v(s) and false of a view or a pointer, and the
difference was already known to the same analysis: Borrowed::owner_is_a_view exists because a view local was
reported as "a local that is destroyed" in dangling-return.
missing-includein a file a compiler does not compile on its own is about the files that include it: the name is missing only if some includer does not already reach the declaring header (an_includer_misses). A header nothing includes is still reported, because nothing makes the name visible there.dangling-fieldstays silent for a view member initialised from a by-value parameter that owns no storage — a view or a pointer. A reference member is unaffected (binding a reference from a view would be a conversion temporary), andS(std::string s) : v(s)still reports.- The
Borrowedflag is renamedowner_owns_nothingand computed for locals (views only — a local pointer is exactlydangling-return's question) and for by-value parameters (views and pointers).
[checks] missing-include 0, dangling-field 0 (the same 198 files)
And the editor's own request, textDocument/diagnostic, asked of the real server for the two reported files:
LuaStyleStruct.h → 0 items, Spell/Util.h → 0 items
(cpp_ls/tests/real_project_hover_probe.rs::the_two_reported_files_have_no_finding_from_the_server).
The true positives stay covered: dangling_field::a_view_member_of_a_by_value_parameter_is_reported,
…::a_pointer_member_of_a_parameters_buffer_is_reported, and missing_include::a_header_inside_its_includers_order_is_silent's
second half, where the same fragment is included by a file that never reaches the enum and is still reported.
real_project_probe.rs::what_the_two_reported_checks_say_about_the_project prints the count.
Reported: opening C:\Users\zc\Desktop\test_cpp made the server's memory grow without bound, especially when
basics.cpp was opened; the process was seen at 5 037 MB.
Measured, and the shape is per opened file. Each opened file gets one whole-program read
(Session::the_program_to_read → Session::read_the_unit): the whole include closure is rendered, parsed and swept
for facts. On this project a closure is a handful of standard headers (~6 MB of source). Sampling the server's own
process (cpp_ls/tests/memory_probe.rs, which opens every .cpp/.h and pulls a diagnostic per file):
8 files opened 951 MB private
19 files opened 5 037 MB private (the report)
19 files, after the budget below 713 MB private / 436 MB working set
The session's own tables during that growth, from CPPLS_TRACE_MEMORY=1 (a pull prints Session::memory_report):
summaries 663 (64.5 MB) | units 8 (32 MB) | cooked 136 (7.9 MB) | views 8 (2 MB)
| index tables 12–23 MB | total ≈ 150 MB
So the tables are bounded and the growth is not in them. It is in the program read: cutting the reads from nineteen to one cuts the process from 5 037 MB to 713 MB — the budget experiment is the causal one.
Not the allocator. The server runs mimalloc (crates/cpp_ls/src/bin/cpp_ls.rs), which does return freed
pages, so "the heap kept the slack" is the wrong explanation, and the first version of this section said it. Whatever a
read allocates is still reachable when it is measured — ~280 MB per read.
What is still open: what holds it. The report's cooked readings grow by ~10 per pull, so Readings::in_a_program
(one CookedFile per file per program, unbounded) is not the answer for these files; the candidates left are the
TranslationUnit the read builds (32 MB accounted, heap_bytes may undercount), the IndexedUnit memo, and the
server-side queues. The next measurement is the resident set sampled at the read_the_unit boundary with the table
sizes printed either side, which names the holder in one run.
Fixed meanwhile: PROGRAM_READ_BUDGET_BYTES (512 MB, estimated at 48 bytes per source byte, so about one
standard-heavy program). Beyond it a file keeps the per-file reading — the reading every file nobody has opened
has — instead of a second whole-program read. Two tests: the budget refuses (a_session_stops_reading_programs_once_ its_memory_budget_is_spent) and a read is charged (a_program_read_is_charged_to_the_budget). The cost is real: the
second and later standard-heavy files of one session lose the compiler's whole-program reading, which is where
cross-file macro scoping is decided. Removing the budget is the goal once the holder is found.
Two include-not-found findings survive the branch rule: <execinfo.h> (log.c:43) and <langinfo.h>
(mbyte.c:2389), behind #ifdef HAVE_EXECINFO_BACKTRACE and #ifdef HAVE_LANGINFO_H. auto/config.h has both as
/* #undef … */ comments, so nothing defines them and a complete environment should decide both #ifdefs
false. Three measurements say why it does not.
- The environment is complete and already what the check uses.
CPPLS_TRACE_MEMORY's siblingtests/guard_probe.rsprintsthe compilation environment is complete;macros_atstarts fromProjectIndex::macros()(the-Ds plus the compiler's-dMtable). Passing the environment in was never the missing piece. - One unresolved
#includetakes the completeness claim back.macro_candidatescallsmark_incompletefor a directive it cannot resolve — a header it never read may define anything — and from then on every unmentioned name isUnknown.tests/guard_probe.rslists 26 such directives inlog.c's closure. - The roots are guards the walk cannot evaluate.
CPPLS_TRACE_GUARD=1shows MinGW's own headers asking__MINGW_GNUC_PREREQ ( 4 , 1 )and the walk answeringNone— itsMacrosHereknows a macro's name and a command-line value, not the body of a function-like macro. So branches this machine does not compile are entered, and the includes inside them (sys/socket.h,mach/mach.h,sys/sdt.h) are “not found”.
Fixed in the same round, one layer down: a header now borrows the include directories of the source beside it
(CompileCommands::a_command_for_a_header, FORMAT_VERSION 25). A build lists only sources, so
src/nvim/os/fs.h had no command and no -isystem .deps/usr/include — eleven headers of the closure
reported <uv.h> unresolved. After the fix the closure's unresolved directives went 32 → 26 and every
uv.h one is gone (6 of them); the remaining 26 are the platform headers above.
Not fixed: giving the walk's condition evaluator the bodies of function-like macros. MacroFact already
carries body_text and parameters, and the cook expands them (MacroView); the walk's evaluator does not, which
is why MinGW's guards are Unknown. That is the next change, and it would let #ifdef HAVE_* be decided on this
machine — and with it the two findings.
Tests pinning today's behaviour: an_include_behind_an_undecided_guard_is_still_reported and
a_header_borrows_the_include_directories_of_a_source_beside_it.
Reported: opening test_cpp/src/containers.cpp (seventeen standard headers) balloons the server. Measured through
the real server (crates/cpp_ls/tests/memory_probe.rs), opening that one file and asking nothing:
0 s 254 MB
10 s 600 MB
20 s 900 MB
35 s 1 375 MB
55 s 2 553 MB (killed by the probe's guard)
With requests it is faster (12 rounds of eight requests took it to 7 500 MB). The session's own tables stay
near 40 MB the whole time (summaries 17.4 MB, views 4.6 MB, index tables 2.3 MB), so the bytes are
transient — something the pump does every round allocates and drops, and the allocator keeps the peaks.
What the pump is doing, in process (crates/cpp_code_analysis/tests/idle_pump_probe.rs, a counting
allocator): after the closure was walked (663 summaries),
queue 0 (open 0, rest 0), refinements wanted 67
reached idle in 10 000 rounds, 13 727 ms, pending 67 ← never reaches zero
every later advance(256): 9 936 allocations, pending 67
pending_work() is 67 and the index queue is empty, so the pump never idles: each round prepares a wave for the
open file's closure, and the refinement fast-path misses (the_stored_refinement → "not stored") 847 times in
35 s while only 106 distinct files are ever cooked (a finite, one-time pass — the cooks are not the leak).
The server's own no-progress breaker (initialized/mod.rs, eight slices with nothing built) drops the cooking
queue, but the asks that keep pending_work() above zero are not the cooking queue, and nothing refuses them.
A real defect found on the way, fixed: the view cache held whole-unit rendering views (measured at ~75 MB of
tree for this file) in the same eight-entry cache as buffer views, so ten cheap views evicted the expensive one and
every request re-parsed the unit. Cache::rendering_views is now a cache of its own (RENDERING_VIEW_ENTRIES,
RENDERING_VIEW_BYTES), pinned by
session::tests::a_rendering_view_survives_twelve_buffer_views — which fails on the shared cache. It does not
explain this report's growth (the unit's rendering here is 3 205 bytes, because its includes are not inlined), so
the growth is still the pump's.
Next: the refinement asks have no cooking_refused equivalent. A file the pass cannot serve is asked for again
on every drain, and each drain pays a wave preparation over the closure. Refuse an ask that has been taken and
served without is_refined becoming true (per content, cleared on edit — the rule cooking_refused already
follows), so pending_work() can reach zero; and call mi_collect(true) after a closure cook burst, since the
peaks are what is being kept.
Second round, measured — the holder is outside every table the session reports, and the growth scales with the closure:
src/main.cpp (few includes): flat at ~510 MB for 20 s idle
src/containers.cpp (17 headers): 2 553 MB in 55 s idle
at 2 334 MB, a diagnostic pull reports: vfs 19 files, summaries 19, tables ~2 MB
So the session holds 19 files and ~2 MB at the moment the process holds 2.3 GB. What is specific to
containers.cpp is its closure: the pump's want_the_closure_cooked names its seventeen direct includes on every
drain ("not read, cookable, not refused" — the prefix stays in parsed_since_the_last_pass), and
prepare_wave/prepare_the_re_read pay a closure-fingerprint walk per file per round (up to 8192 files
each), with the refinement fast path missing every time (847 skipped: not stored in 35 s).
Tried and measured as not the cause, and reverted rather than kept:
mi_collect(true)from the pump and from the blocking threads that cook (7 calls in 10 s): no change;- redb's page cache (default 1 GiB; now capped at 64 MB — kept as a bound, but it moved this number not at all);
- making the pump wait instead of yield when its backlog survives the no-progress breaker: no change (the breaker was not the spin).
Next: a heap profile outside the session (mimalloc's own stats, or a sampling profiler) to name the allocation
site, and a bisect of the seventeen includes (a file including only <map>, only <variant>, …) to find whether
one header triggers the loop.
The trigger, bisected to one statement (temp files under test_cpp/src, deleted afterwards; the server opened
one file and asked nothing for 15 s):
17 includes + one trivial function flat ~535 MB
the same with only the first 8 / only the last 9 flat ~559 MB
17 includes + sequences() and associative() flat ~518 MB
17 includes + `struct Value` (std::visit) flat ~505 MB
containers.cpp without vocabulary() flat ~568 MB
vocabulary() alone, in its own file 2 555 MB
its optional+variant half flat ~539 MB
its any/tuple/pair half 2 512 MB
std::any alone flat ~565 MB
the tuple statements 2 595 MB
`tuple_size_v<decltype(t)>` flat ~548 MB
`std::get<0>(tuple)` 2 585 MB ← the trigger
So the smallest reproducer is a file with the seventeen includes and
std::tuple tuple{1, 2.5, std::string{"three"}};
std::cout << std::get<0>(tuple);inspect diagnostics --index on that file is fast and quiet (215 MB, 0 findings), so the loop is not in
the check layer: it is in the server's pump path that the standalone tool does not run. The next question is
therefore the pump's own, and the probe that answers it is one request order away: does the cook of that file
land (cooked_declarations Some) or is it re-wanted every round? The in-process pump probe cannot see it — it
does not cook at all (cooking 67, cooked 0), because cooking belongs to the server's claim_cooking/
render_cooks path.
Third round — two fixes landed, and the residue is now one request.
Fixed and measured:
- The pump's no-work round waits. It used to
yield_now()and continue, so a backlog it could not move spun at thousands of rounds a second. WithCPPLS_NO_COOKSset, that loop took private bytes 476 MB → 1 228 MB in eight seconds while every stage the pump measures allocated 0 MB; with the wait the same scenario holds flat at 422 MB for fifteen seconds. - A stored-refinement miss is remembered by
(path, content hash).prepare_waveasked the disk cache for the slice's files on every round — a whole-closure walk per file, and a miss that cannot change for a text (nothing is storable). Measured:wave 20 MBon every round withprepared 0; nowwave 0 MB.
The residue, bisected to one request:
idle, no request flat at 422 MB for 15 s (after the fix)
one textDocument/diagnostic grows ~90 MB/s, one core at 100 %, 21 threads
pump rounds < 100/s (not the pump), no watcher, no cooks,
every pump stage 0 MB, session tables 66-73 MB
So the bytes are in the request path after the pull, not in the pump and not in the checks:
inspect diagnostics --index --cook on the same file is 212 MB and instant. The next instrument is the same
allocation counter around AnalysisState::catch_up, AnalysisState::prepare and Session::diagnostics, printing
per request — that names the call in one run.
The pull path, split (CPPLS_TRACE_ALLOCS prints [pull] and [query]):
[pull] prepare done, 117 MB ← prepare_for_a_diagnostic, per pull
[pull] diagnose_file asked for 5 MB
[pull] query returned, 8 MB after the prepare
[pull] prepare done, 0 MB ← the second pull, and then no further line
So a pull is not the 90 MB/s — it allocates 117 MB once, in prepare_for_a_diagnostic — and the growth after it
is something that starts then and keeps going: with the pump waiting (the fix above) its rounds allocate 0 MB,
pending stays 66, and no stage runs; [query] lines show every query it did run at 0–6 ms. The next question is
what runs at 100 % of a core outside both: the trace to add is thread-level, not stage-level.
The livelock, found and fixed. commit_a_wave broke out of its commit loop when the head of the queue was an
open document with no prepared reading — and prepare_wave defers open documents, so it never has one. Nothing
committed, the head was never popped, and the pump prepared the same wave for ever: measured after one diagnostic
pull on containers.cpp, 17 rounds a second, one full core, ~90 MB/s, pending stuck at 66 with every session
table at 66 MB. index_one builds the summary itself when the wave left the file alone — the treatment advance
already gives it — so the guard is gone. Measured after: pending drains 66 → 8 → 4 → 0.
What is left is the peak, not a loop: with pending at zero the process still reached 2.8 GB over 40 s, and the
pump's rounds at that point allocate nothing measurable — the last unexplained number is ~16 query_blocking calls
a second with no request arriving, which is either refresh_pull/schedule_open_files re-running or a query's own
work; the next instrument is those two calls with the counter.
Fourth round — the peak was the program reads, and the budget was never paid.
The instrument that named it was an experiment switch: CPPLS_NO_PROGRAM_READ=1 made the twenty-open-file run
flat at 511 MB for 60 s on the same tree that grew to 1 094 MB in 34 s without it. A whole-program read is the
one operation that allocates hundreds of megabytes at once, and PROGRAM_READ_BUDGET_BYTES (512 MB) exists so that
twenty open tabs do not cost twenty of them — but the charge lived in Session::read_the_unit, which is not the
path the pump takes: the pump goes AnalysisState::read_a_program → Session::commit_a_program, and that path
charged nothing, so every open file's program was read however much the previous ones had cost.
Three changes, measured after a cold cache with a watchdog that samples private bytes every 200 ms and kills at 1 GB or two minutes — the harness has a bound because the bug is unbounded:
before after
twenty files open, no request 1 094 MB at 34 s (killed) 515-550 MB flat, 60 s
one file + the editor's requests 604 MB peak at 1 s, exits in 6.5 s
CPPLS_NO_PROGRAM_READ on that run 511 MB flat for 60 s
- The budget is charged in
commit_a_program, the one place every path goes through. Test:session::tests::a_program_read_charges_the_budget_that_bounds_it— 0 charged on the previous tree. - A declined program read waits.
read_a_programanswersfalsewhen the file it chose is over budget, and such a file is deliberately re-considered on every drain; the pump used tocontinuewith no wait, and re-considering walks the closure of every open file to price it. Twenty tabs spun on twenty closure walks. - A file is asked to be cooked once per text.
want_the_closure_cookedruns per open document per drain and asked the index's bounded cooked map, so an evicted reading read as "nobody cooked this" and the file was queued again: 486 cook commits for 179 distinct files in 33 s, before the fix. The ask-once set is cleared where a file's text moves (buffer_changed) or its dependents are invalidated.
Left open: the first programme read of a standard-heavy file still peaks around 600 MB, which is the feature's own cost (one parse of the whole program) rather than a leak; a project with twenty tabs open now pays for one such program and keeps its own reading for the rest.