From f67df05b6a100e275288390cb79b1ef71e631ca1 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 26 Sep 2026 07:15:57 -0400 Subject: [PATCH 1/3] Warm the settings redactor's patterns before their first timed match (#4348) --- .../Darling.Tests/PgSettingRedactorTests.cs | 18 +++++++ .../PgSettingRedactor.cs | 49 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/Darling/Darling.Tests/PgSettingRedactorTests.cs b/Darling/Darling.Tests/PgSettingRedactorTests.cs index 6a9849cdb3..91a4b47840 100644 --- a/Darling/Darling.Tests/PgSettingRedactorTests.cs +++ b/Darling/Darling.Tests/PgSettingRedactorTests.cs @@ -518,4 +518,22 @@ public void ForcedTimeout_ThrowingCallback_StillReturnsMaskAndDoesNotThrow() PgSettingRedactor.MatchTimeoutForTest = null; } } + + /// + /// #4348: every Compiled pattern is warmed at type initialisation, before any real + /// call can return, so the first real call never pays a + /// first-use JIT cost against the 100ms match timeout. This pins that the warmup ran (rather than + /// re-deriving that fact from timing, which would be flaky by construction on a busy runner) and that a + /// short, well-formed value is masked in PART, not masked whole, the way a spurious timeout would + /// produce. + /// + [Fact] + public void Warmup_RanBeforeFirstRealCall_AndShortValueIsNotMaskedWhole() + { + Assert.True(PgSettingRedactor.WarmedUp); + + var result = PgSettingRedactor.Redact("archive_command", "PGPASSWORD=hunter2 psql -c 'select 1'"); + + Assert.Equal("PGPASSWORD=******** psql -c 'select 1'", result); + } } diff --git a/PerformanceMonitor.Collectors/PgSettingRedactor.cs b/PerformanceMonitor.Collectors/PgSettingRedactor.cs index bfcabc086c..f0d8df3bd5 100644 --- a/PerformanceMonitor.Collectors/PgSettingRedactor.cs +++ b/PerformanceMonitor.Collectors/PgSettingRedactor.cs @@ -114,6 +114,25 @@ public string Replace(string input, MatchEvaluator evaluator) => (MatchTimeoutForTest is TimeSpan overrideTimeout ? new Regex(_pattern, _options, overrideTimeout) : _default).Replace(input, evaluator); + + /// Runs the DEFAULT (production-timeout) instance once on a short, benign string, outside + /// any timed accounting the caller does (#4348): a pattern's IL + /// is JITted on its first invocation, and on a busy CI runner that JIT cost alone can exceed the + /// 100 ms , turning the first real call on a short, well-formed value + /// into a spurious whole-value mask. Warming here, at type initialisation, pays that cost once, + /// before the type is usable at all, so the first real call never has to. A + /// timeout during warmup (the JIT itself, on an especially slow runner, taking longer than the + /// timeout) is swallowed — warmup exists to pre-pay JIT cost, not to prove the pattern is fast. + public void Warmup() + { + try + { + _default.IsMatch("x"); + } + catch (RegexMatchTimeoutException) + { + } + } } /// Names whose value is masked in full when the setting is extension-scoped (#4348), or when a @@ -277,6 +296,36 @@ public string Replace(string input, MatchEvaluator evaluator) => "pwd", }; + /// Set once, before any real call can return (#4348): the static + /// constructor below warms every pattern's first-use JIT cost before it counts + /// against anyone's . Exposed only so a test can pin that warmup ran ahead of + /// the first real call, without the test having to spin up a fresh AssemblyLoadContext just to + /// observe type-initialisation order. + internal static readonly bool WarmedUp; + + static PgSettingRedactor() + { + LibpqPasswordKeyword.Warmup(); + UriQueryPassword.Warmup(); + AssignmentSecretName.Warmup(); + OptionSecretSpaced.Warmup(); + UriQueryKeyAnyEncoding.Warmup(); + UriQuerySignature.Warmup(); + QuotedSpacedAssignment.Warmup(); + CurlUserColon.Warmup(); + SshpassOption.Warmup(); + + try + { + UriUserInfoPassword.IsMatch("x"); + } + catch (RegexMatchTimeoutException) + { + } + + WarmedUp = true; + } + private static bool IsQueryKeySecret(string rawKey) { string decoded; From 352f2171e4f667cca3c2aad8eccdc84e54cf61d5 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 26 Sep 2026 07:38:37 -0400 Subject: [PATCH 2/3] Warm each redactor pattern through its match step (#4348) --- .../Darling.Tests/PgSettingRedactorTests.cs | 17 +++++++ .../PgSettingRedactor.cs | 49 ++++++++++++++----- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/Darling/Darling.Tests/PgSettingRedactorTests.cs b/Darling/Darling.Tests/PgSettingRedactorTests.cs index 91a4b47840..71356034cd 100644 --- a/Darling/Darling.Tests/PgSettingRedactorTests.cs +++ b/Darling/Darling.Tests/PgSettingRedactorTests.cs @@ -536,4 +536,21 @@ public void Warmup_RanBeforeFirstRealCall_AndShortValueIsNotMaskedWhole() Assert.Equal("PGPASSWORD=******** psql -c 'select 1'", result); } + + /// + /// #4348: warmup must reach each pattern's MATCH step, not only its scan (TryFindNextPossibleStartingPosition + /// finding a candidate but TryMatchAtCurrentPosition never running). This pins that the warmup + /// sample every is warmed with actually produces at + /// least one match for EVERY pattern in the list, so the match step is exercised, not skipped. + /// + [Fact] + public void WarmupSample_MatchesEveryWarmedPattern() + { + Assert.NotEmpty(PgSettingRedactor.WarmedPatterns); + + foreach (var pattern in PgSettingRedactor.WarmedPatterns) + { + Assert.True(pattern.WarmupSampleMatches()); + } + } } diff --git a/PerformanceMonitor.Collectors/PgSettingRedactor.cs b/PerformanceMonitor.Collectors/PgSettingRedactor.cs index f0d8df3bd5..b11edecd5b 100644 --- a/PerformanceMonitor.Collectors/PgSettingRedactor.cs +++ b/PerformanceMonitor.Collectors/PgSettingRedactor.cs @@ -66,6 +66,11 @@ public static class PgSettingRedactor private const string Mask = "********"; + /// A sample that reaches every pattern's match step (#4348), used only to warm each compiled + /// pattern's JIT ahead of the first real call. Holds placeholder values only. + private const string WarmupSample = + "password=x sslpassword=y postgresql://u:p@h/?password=z&sig=s&X-Amz-Signature=t \"password\" = \"q\" --password w sshpass -p v curl -u a:b"; + /// /// A per-regex match-time bound (#4348): a pathological value (a long run with no separator, feeding one /// of the lookaround-heavy patterns below) could otherwise pin the engine backtracking well past the @@ -97,7 +102,7 @@ internal static TimeSpan? MatchTimeoutForTest /// Wraps one lookaround-bearing pattern so it always runs under a match timeout, while still /// letting a test force a much shorter one via without rebuilding /// every call on the production path. - private sealed class TimeBoundPattern + internal sealed class TimeBoundPattern { private readonly string _pattern; private readonly RegexOptions _options; @@ -115,24 +120,30 @@ public string Replace(string input, MatchEvaluator evaluator) => ? new Regex(_pattern, _options, overrideTimeout) : _default).Replace(input, evaluator); - /// Runs the DEFAULT (production-timeout) instance once on a short, benign string, outside - /// any timed accounting the caller does (#4348): a pattern's IL - /// is JITted on its first invocation, and on a busy CI runner that JIT cost alone can exceed the - /// 100 ms , turning the first real call on a short, well-formed value - /// into a spurious whole-value mask. Warming here, at type initialisation, pays that cost once, - /// before the type is usable at all, so the first real call never has to. A - /// timeout during warmup (the JIT itself, on an especially slow runner, taking longer than the - /// timeout) is swallowed — warmup exists to pre-pay JIT cost, not to prove the pattern is fast. + /// Runs the DEFAULT (production-timeout) instance once on a sample that reaches every + /// pattern's match step, outside any timed accounting the caller does (#4348): a + /// pattern's IL is JITted on its first invocation, and on a busy + /// CI runner that JIT cost alone can exceed the 100 ms , turning the + /// first real call on a short, well-formed value into a spurious whole-value mask. Warming here, at + /// type initialisation, pays that cost once, before the type is usable at all, so the first real + /// call never has to. A timeout during warmup (the JIT itself, on an especially + /// slow runner, taking longer than the timeout) is swallowed — warmup exists to pre-pay JIT cost, + /// not to prove the pattern is fast. public void Warmup() { try { - _default.IsMatch("x"); + _default.IsMatch(WarmupSample); } catch (RegexMatchTimeoutException) { } } + + /// Runs 's sample through this pattern and reports whether it produced + /// at least one match (#4348) — used only by the test that pins that warmup reaches every pattern's + /// match step, not only its scan. + internal bool WarmupSampleMatches() => _default.IsMatch(WarmupSample); } /// Names whose value is masked in full when the setting is extension-scoped (#4348), or when a @@ -317,7 +328,7 @@ static PgSettingRedactor() try { - UriUserInfoPassword.IsMatch("x"); + UriUserInfoPassword.IsMatch(WarmupSample); } catch (RegexMatchTimeoutException) { @@ -326,6 +337,22 @@ static PgSettingRedactor() WarmedUp = true; } + /// Every this type warms at type initialisation (#4348) — + /// exposed only so a test can pin that the warmup sample actually reaches each one's match step, not + /// only its scan. + internal static readonly TimeBoundPattern[] WarmedPatterns = + { + LibpqPasswordKeyword, + UriQueryPassword, + AssignmentSecretName, + OptionSecretSpaced, + UriQueryKeyAnyEncoding, + UriQuerySignature, + QuotedSpacedAssignment, + CurlUserColon, + SshpassOption, + }; + private static bool IsQueryKeySecret(string rawKey) { string decoded; From 678bb17e0922cf485257516e49d94a7da18af7dc Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 26 Sep 2026 07:47:05 -0400 Subject: [PATCH 3/3] Warm the redactor through a full redaction, and never fail type initialisation (#4348) --- .../PgSettingRedactor.cs | 68 ++++++++++++------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/PerformanceMonitor.Collectors/PgSettingRedactor.cs b/PerformanceMonitor.Collectors/PgSettingRedactor.cs index b11edecd5b..ea58d7551d 100644 --- a/PerformanceMonitor.Collectors/PgSettingRedactor.cs +++ b/PerformanceMonitor.Collectors/PgSettingRedactor.cs @@ -131,11 +131,15 @@ public string Replace(string input, MatchEvaluator evaluator) => /// not to prove the pattern is fast. public void Warmup() { + // Catch-all, not only the timeout (#4348): ANY exception here, uncaught, would fail the static + // constructor and leave the type permanently unusable (every later call throws + // TypeInitializationException) — warmup exists to pre-pay a cost, never to gate whether the type + // works at all. try { _default.IsMatch(WarmupSample); } - catch (RegexMatchTimeoutException) + catch { } } @@ -314,32 +318,11 @@ public void Warmup() /// observe type-initialisation order. internal static readonly bool WarmedUp; - static PgSettingRedactor() - { - LibpqPasswordKeyword.Warmup(); - UriQueryPassword.Warmup(); - AssignmentSecretName.Warmup(); - OptionSecretSpaced.Warmup(); - UriQueryKeyAnyEncoding.Warmup(); - UriQuerySignature.Warmup(); - QuotedSpacedAssignment.Warmup(); - CurlUserColon.Warmup(); - SshpassOption.Warmup(); - - try - { - UriUserInfoPassword.IsMatch(WarmupSample); - } - catch (RegexMatchTimeoutException) - { - } - - WarmedUp = true; - } - /// Every this type warms at type initialisation (#4348) — /// exposed only so a test can pin that the warmup sample actually reaches each one's match step, not - /// only its scan. + /// only its scan. The static constructor below drives its warm-up from THIS list, plus + /// if it is not already in it, so a pattern added here can't be + /// missed from one of the two. internal static readonly TimeBoundPattern[] WarmedPatterns = { LibpqPasswordKeyword, @@ -353,6 +336,41 @@ static PgSettingRedactor() SshpassOption, }; + static PgSettingRedactor() + { + // Catch-all around every warm-up step, not only the timeout (#4348): ANY exception escaping the + // static constructor fails type initialisation, and every later Redact call would then throw + // TypeInitializationException forever — warmup exists to pre-pay JIT cost, never to gate whether the + // type works at all. + foreach (var pattern in WarmedPatterns) + { + pattern.Warmup(); + } + + try + { + UriUserInfoPassword.IsMatch(WarmupSample); + } + catch + { + } + + // Warms the evaluator lambdas too, not only the patterns' match step (#4348): the match-timeout + // clock spans a whole Regex.Replace call, including its MatchEvaluator, so a full Redact call here + // JITs those delegates before any real call has to. Safe to call from the static constructor because + // every field Redact reads (the patterns above, Mask, QueryKeySecretMarkers) is field-initialised, + // and field initialisers run before this constructor body. + try + { + Redact(null, WarmupSample); + } + catch + { + } + + WarmedUp = true; + } + private static bool IsQueryKeySecret(string rawKey) { string decoded;