From cd2987d57f9f41108b966066cbaffef671ed9cf2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 18:30:07 +0900 Subject: [PATCH 1/2] Fix C# symbol extraction stall (#2740) --- changelog.d/unreleased/2740.fixed.md | 16 +++++++++++++ .../Symbols/SymbolExtractor.CSharpScanner.cs | 23 +++++++++++++++++++ tests/CodeIndex.Tests/SymbolExtractorTests.cs | 18 +++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 changelog.d/unreleased/2740.fixed.md diff --git a/changelog.d/unreleased/2740.fixed.md b/changelog.d/unreleased/2740.fixed.md new file mode 100644 index 0000000000..9447bfc450 --- /dev/null +++ b/changelog.d/unreleased/2740.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2740 +affected: + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs + - tests/CodeIndex.Tests/SymbolExtractorTests.cs +--- + +## English + +- **C# symbol extraction no longer repeatedly rescans method bodies as field candidates (#2740)** — large C# files such as `SymbolExtractor.JavaScriptTypeScriptSupport.cs` now avoid a quadratic property-header fallback during full indexing. + +## 日本語 + +- **C# シンボル抽出がメソッド本体をフィールド候補として繰り返し再走査しなくなりました (#2740)** — `SymbolExtractor.JavaScriptTypeScriptSupport.cs` のような大きな C# ファイルで、full index 中の property header fallback が二乗的に重くなる経路を避けます。 diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs index 912f39fb3a..c7d768fa87 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs @@ -1798,6 +1798,14 @@ private static CSharpPropertyMatchCandidate BuildCSharpPropertyMatchLine(string[ var isPropertyHeaderPrefix = CSharpPropertyHeaderPrefixRegex.IsMatch(matchLine); var isMethodHeaderPrefix = CSharpMethodHeaderPrefixRegex.IsMatch(matchLine); + if (!isPropertyHeaderPrefix + && isMethodHeaderPrefix + && matchLine.IndexOf('(') >= 0 + && (matchLine.IndexOf('{') >= 0 || matchLine.IndexOf(';') >= 0)) + { + return new CSharpPropertyMatchCandidate(matchLine, startLineIndex, startLineIndex); + } + if (string.IsNullOrWhiteSpace(matchLine) || (!isPropertyHeaderPrefix && !isMethodHeaderPrefix) || HasCSharpPropertyAccessorStart(matchLine) @@ -1928,6 +1936,7 @@ private static bool IsCSharpNonMemberHeaderLine(string line) || trimmed.StartsWith("using ", StringComparison.Ordinal) || trimmed.StartsWith("global using ", StringComparison.Ordinal) || trimmed.StartsWith("extern alias ", StringComparison.Ordinal) + || trimmed.StartsWith("var ", StringComparison.Ordinal) || trimmed.StartsWith("//", StringComparison.Ordinal); } @@ -1962,6 +1971,12 @@ private static CSharpPropertyMatchCandidate ContinueConfirmedCSharpPropertyMatch openBraceLineIndex, openBraceExclusiveEndColumn); } + + if (accessorProbeStatus == CSharpAccessorProbeStatus.Rejected + && CSharpConfirmedMethodPrefixRegex.IsMatch(normalizedCombined)) + { + return new CSharpPropertyMatchCandidate(normalizedCombined, currentLineIndex, currentLineIndex); + } } for (int i = currentLineIndex + 1; i < csharpMatchLines.Length; i++) @@ -1983,6 +1998,14 @@ private static CSharpPropertyMatchCandidate ContinueConfirmedCSharpPropertyMatch openBraceExclusiveEndColumn.Value, i); accessorProbeStatus = ClassifyCSharpAccessorProbe(accessorProbeBuilder.ToString()); + if (accessorProbeStatus == CSharpAccessorProbeStatus.Rejected + && CSharpConfirmedMethodPrefixRegex.IsMatch(CollapseCSharpGenericTypeWhitespace(builder.ToString()))) + { + return new CSharpPropertyMatchCandidate( + CollapseCSharpGenericTypeWhitespace(builder.ToString()), + i, + i); + } } else if (accessorProbeBuilder != null && accessorProbeStatus == CSharpAccessorProbeStatus.Pending) diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index 0501d8fb48..0aa5e45a70 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -128,6 +128,24 @@ public interface IAddable Assert.DoesNotContain(symbols, symbol => symbol.Kind == "function" && symbol.Name.StartsWith("operator", StringComparison.Ordinal)); } + [Fact] + public void Extract_CsharpManyMethods_DoesNotRescanMethodBodiesAsFieldCandidates() + { + var methods = Enumerable.Range(0, 80).Select(i => $$""" + public void M{{i}}() + { + var value = {{i}}; + value++; + } + """); + var content = "public class ManyMethods\n{\n" + string.Join('\n', methods) + "\n}"; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + + Assert.Equal(80, symbols.Count(symbol => symbol.Kind == "function" && symbol.Name.StartsWith("M", StringComparison.Ordinal))); + Assert.DoesNotContain(symbols, symbol => symbol.Kind == "function" && symbol.Signature?.Contains("value++", StringComparison.Ordinal) == true); + } + [Fact] public void Extract_PythonDataclassField_IndexesFieldAndMetadataKeys() { From 03c1e7fe30a0232bf23b761d8532dd203cee53c4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 18:30:21 +0900 Subject: [PATCH 2/2] Add extraction allocation budgets (#1667) --- changelog.d/unreleased/1667.internal.md | 15 +++++++ tests/CodeIndex.Tests/PerformanceTests.cs | 52 +++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 changelog.d/unreleased/1667.internal.md diff --git a/changelog.d/unreleased/1667.internal.md b/changelog.d/unreleased/1667.internal.md new file mode 100644 index 0000000000..0d2e565139 --- /dev/null +++ b/changelog.d/unreleased/1667.internal.md @@ -0,0 +1,15 @@ +--- +category: internal +issues: + - 1667 +affected: + - tests/CodeIndex.Tests/PerformanceTests.cs +--- + +## English + +- **Added CI allocation budgets for extraction hot paths (#1667)** — symbol and reference extraction now have fixed C# fixture allocation checks to catch memory-pressure regressions before indexing slows down. + +## 日本語 + +- **抽出 hot path 向けの CI allocation budget を追加しました (#1667)** — symbol extraction と reference extraction に固定 C# fixture の allocation 検査を加え、indexing が遅くなる前にメモリ負荷の回帰を検出します。 diff --git a/tests/CodeIndex.Tests/PerformanceTests.cs b/tests/CodeIndex.Tests/PerformanceTests.cs index 043fc5ffad..c1dbc751e8 100644 --- a/tests/CodeIndex.Tests/PerformanceTests.cs +++ b/tests/CodeIndex.Tests/PerformanceTests.cs @@ -112,6 +112,58 @@ public void ExtractLargeSameLineSymbolFixture_CompletesInReasonableTime() Assert.Equal(4_000, symbols.Count); } + [Fact] + public void SymbolExtraction_CsharpHotPath_StaysWithinAllocationBudget() + { + var content = BuildCSharpHotPathFixture(typeCount: 120); + _ = SymbolExtractor.Extract(1, "csharp", content); + + var allocatedBytes = MeasureAllocatedBytes(() => SymbolExtractor.Extract(1, "csharp", content)); + + Assert.True(allocatedBytes < 18_000_000, $"Symbol extraction allocated {allocatedBytes:N0} bytes"); + } + + [Fact] + public void ReferenceExtraction_CsharpHotPath_StaysWithinAllocationBudget() + { + var content = BuildCSharpHotPathFixture(typeCount: 80); + var symbols = SymbolExtractor.Extract(1, "csharp", content); + _ = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var allocatedBytes = MeasureAllocatedBytes(() => ReferenceExtractor.Extract(1, "csharp", content, symbols)); + + Assert.True(allocatedBytes < 18_000_000, $"Reference extraction allocated {allocatedBytes:N0} bytes"); + } + + private static long MeasureAllocatedBytes(Action action) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + var before = GC.GetAllocatedBytesForCurrentThread(); + action(); + return GC.GetAllocatedBytesForCurrentThread() - before; + } + + private static string BuildCSharpHotPathFixture(int typeCount) + { + return string.Join( + "\n", + Enumerable.Range(0, typeCount).Select(i => $$""" + public sealed class Service{{i}} + { + private readonly Dependency{{i}} dependency; + public Service{{i}}(Dependency{{i}} dependency) => this.dependency = dependency; + public Result{{i}} Execute(Request{{i}} request) + { + var value = dependency.Transform(request.Value); + return new Result{{i}}(value); + } + } + """)); + } + public void Dispose() { _db.Dispose();