Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions changelog.d/unreleased/3252.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
category: fixed
issues:
- 3252
affected:
- src/CodeIndex/Indexer/Hooks/PostExtractionHookCallbackWorker.cs
---

## English

- **Post-extraction hook callbacks now reject late over-budget responses (#3252)** — hook worker responses are discarded when the measured callback elapsed time already exceeds `CDIDX_HOOK_CALLBACK_BUDGET_MS`, preventing timed-out mutations from being accepted under full-suite scheduler contention.

## 日本語

- **post-extraction hook callback が遅れて返した budget 超過 response を拒否するようになりました (#3252)** — 測定済み callback elapsed time が `CDIDX_HOOK_CALLBACK_BUDGET_MS` を超えている場合は hook worker response を破棄し、full suite の scheduler contention 下で timeout 済み mutation が採用されないようにしました。
15 changes: 15 additions & 0 deletions changelog.d/unreleased/3261.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
category: fixed
issues:
- 3261
affected:
- tests/CodeIndex.Tests/PostExtractionHookTests.cs
---

## English

- **PostExtractionHookTests now use wider callback-budget timing margins (#3261)** — the slow hook budget test now keeps the hook delay well beyond the test budget under full-suite load while still checking that timed-out workers are killed before they can signal completion.

## 日本語

- **`PostExtractionHookTests` の callback budget test がより広い timing margin を使うようになりました (#3261)** — slow hook budget test は full suite load 下でも hook delay が test budget を十分に超える余裕を持たせつつ、timeout した worker が completion を通知する前に kill されることを引き続き検証します。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/3292.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: fixed
issues:
- 3292
affected:
- src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs
- tests/CodeIndex.Tests/DiagnosticSanitizerTests.cs
---

## English

- **Diagnostic sanitization no longer leaks regex timeout exceptions (#3292)** — plugin and pattern diagnostics now fall back to a generic sanitized message if path redaction hits its bounded regex timeout, so full-suite runs do not fail while reporting plugin registry status.

## 日本語

- **診断 sanitization が regex timeout 例外を漏らさないようになりました (#3292)** — plugin / pattern 診断の path redaction が上限付き regex timeout に達した場合は汎用の sanitized message に fallback するため、plugin registry status の報告中に full suite が失敗しなくなりました。
40 changes: 38 additions & 2 deletions src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ namespace CodeIndex.Diagnostics;
internal static class DiagnosticSanitizer
{
private const int MaxDiagnosticFieldLength = 240;
private const int MaxSanitizerInputLength = MaxDiagnosticFieldLength * 8;
internal const string RegexTimeoutFallbackMessage = "[message omitted after sanitization timeout]";
private static readonly Regex AbsolutePathPattern = new(
@"(?:[A-Za-z]:)?[/\\][^\s'"";:,)]+",
RegexOptions.Compiled | RegexOptions.CultureInvariant,
Expand Down Expand Up @@ -32,6 +34,9 @@ public static string ForPath(string? path)
=> string.IsNullOrWhiteSpace(value) ? value : ForMessage(value);

public static string ForMessage(string? message)
=> ForMessage(message, value => AbsolutePathPattern.Replace(value, "<path>"));

internal static string ForMessage(string? message, Func<string, string> redactPaths)
{
if (string.IsNullOrWhiteSpace(message))
return string.Empty;
Expand All @@ -40,8 +45,18 @@ public static string ForMessage(string? message)
.Replace('\r', ' ')
.Replace('\n', ' ')
.Replace('\t', ' ');
var withoutPaths = AbsolutePathPattern.Replace(singleLine, "<path>");
return Truncate(Regex.Replace(withoutPaths, @"\s{2,}", " ", RegexOptions.None, TimeSpan.FromMilliseconds(50)).Trim());
if (singleLine.Length > MaxSanitizerInputLength)
singleLine = singleLine[..MaxSanitizerInputLength] + " ...";

try
{
var withoutPaths = redactPaths(singleLine);
return Truncate(CollapseWhitespace(withoutPaths).Trim());
}
catch (RegexMatchTimeoutException)
{
return RegexTimeoutFallbackMessage;
}
}

private static string TryGetFullPath(string path)
Expand All @@ -59,6 +74,27 @@ private static string TryGetFullPath(string path)
private static string NormalizeSeparators(string value)
=> value.Replace('\\', '/');

private static string CollapseWhitespace(string value)
{
var collapsed = new System.Text.StringBuilder(value.Length);
var previousWasWhitespace = false;
foreach (var character in value)
{
if (char.IsWhiteSpace(character))
{
if (!previousWasWhitespace)
collapsed.Append(' ');
previousWasWhitespace = true;
continue;
}

collapsed.Append(character);
previousWasWhitespace = false;
}

return collapsed.ToString();
}

private static string Truncate(string value)
=> value.Length <= MaxDiagnosticFieldLength
? value
Expand Down
10 changes: 10 additions & 0 deletions src/CodeIndex/Indexer/Hooks/PostExtractionHookCallbackWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ internal PostExtractionHookCallbackResult Invoke(
return Failure($"failed to read worker response: {responseException.Message}", stopwatch.ElapsedMilliseconds);
}

if (CallbackBudgetExceeded(stopwatch, callbackBudget))
{
KillWorker();
stopwatch.Stop();
return TimedOut(stopwatch.ElapsedMilliseconds);
}

stopwatch.Stop();
var responseJson = responseTask.GetAwaiter().GetResult();
if (responseJson == null)
Expand Down Expand Up @@ -300,6 +307,9 @@ private static int GetRemainingWaitMilliseconds(Stopwatch stopwatch, TimeSpan ca
return Math.Max(1, (int)Math.Ceiling(Math.Min(remainingMilliseconds, int.MaxValue)));
}

private static bool CallbackBudgetExceeded(Stopwatch stopwatch, TimeSpan callbackBudget)
=> stopwatch.Elapsed > callbackBudget;

private static string BuildWorkerExitError(Process? process, string stderr, string fallback)
{
var exitCodeText = process == null
Expand Down
30 changes: 30 additions & 0 deletions tests/CodeIndex.Tests/DiagnosticSanitizerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Text.RegularExpressions;
using CodeIndex.Diagnostics;

namespace CodeIndex.Tests;

public class DiagnosticSanitizerTests
{
[Fact]
public void ForMessage_RedactsPathsAndCollapsesWhitespace()
{
var sanitized = DiagnosticSanitizer.ForMessage("failed\nat /tmp/codeindex/plugins/bad.dll\twith details");

Assert.Equal("failed at <path> with details", sanitized);
}

[Fact]
public void ForMessage_RedactionTimeout_ReturnsFallbackMessage()
{
var timeout = new RegexMatchTimeoutException(
"load failed at /tmp/codeindex/plugins/bad.dll",
"path",
TimeSpan.FromMilliseconds(50));

var sanitized = DiagnosticSanitizer.ForMessage(
"load failed at /tmp/codeindex/plugins/bad.dll",
_ => throw timeout);

Assert.Equal(DiagnosticSanitizer.RegexTimeoutFallbackMessage, sanitized);
}
}
8 changes: 4 additions & 4 deletions tests/CodeIndex.Tests/PostExtractionHookTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,8 @@ public void CallbackBudgetExceeded_KillsWorkerAndSkipsTimedOutMutation()
var originalBudget = PostExtractionHookRunner.CallbackBudgetForTesting;
try
{
env.Set(SlowHookDelayEnvironmentVariable, "200");
PostExtractionHookRunner.CallbackBudgetForTesting = () => TimeSpan.FromMilliseconds(50);
env.Set(SlowHookDelayEnvironmentVariable, "500");
PostExtractionHookRunner.CallbackBudgetForTesting = () => TimeSpan.FromMilliseconds(100);
var hooksDir = Path.Combine(projectRoot, "hooks");
var completionPath = Path.Combine(projectRoot, "slow-hook.done");
env.Set(SlowHookCompletionPathEnvironmentVariable, completionPath);
Expand All @@ -178,7 +178,7 @@ public void CallbackBudgetExceeded_KillsWorkerAndSkipsTimedOutMutation()
var symbols = new List<SymbolRecord>();

runner.OnSymbolsExtracted(context, symbols);
AssertFileDoesNotAppear(completionPath, TimeSpan.FromMilliseconds(750));
AssertFileDoesNotAppear(completionPath, TimeSpan.FromMilliseconds(1000));

Assert.DoesNotContain(symbols, symbol => symbol.Name == "SlowHookTag");
var diagnostic = Assert.Single(
Expand All @@ -191,7 +191,7 @@ public void CallbackBudgetExceeded_KillsWorkerAndSkipsTimedOutMutation()
// The worker wait can time out at the budget boundary before
// ElapsedMilliseconds rounds up to the full budget on some CI hosts.
Assert.True(diagnostic.DurationMs > 0);
Assert.Equal(50, (long)Math.Round(runner.CallbackBudget.TotalMilliseconds, MidpointRounding.AwayFromZero));
Assert.Equal(100, (long)Math.Round(runner.CallbackBudget.TotalMilliseconds, MidpointRounding.AwayFromZero));
}
CollectUnloadedHookAssemblies();
}
Expand Down
Loading