diff --git a/Darling/Darling.Tests/PgSettingRedactorTests.cs b/Darling/Darling.Tests/PgSettingRedactorTests.cs
index 6a9849cdb3..71356034cd 100644
--- a/Darling/Darling.Tests/PgSettingRedactorTests.cs
+++ b/Darling/Darling.Tests/PgSettingRedactorTests.cs
@@ -518,4 +518,39 @@ 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);
+ }
+
+ ///
+ /// #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 bfcabc086c..ea58d7551d 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;
@@ -114,6 +119,35 @@ 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 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()
+ {
+ // 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
+ {
+ }
+ }
+
+ /// 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
@@ -277,6 +311,66 @@ 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;
+
+ /// 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. 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,
+ UriQueryPassword,
+ AssignmentSecretName,
+ OptionSecretSpaced,
+ UriQueryKeyAnyEncoding,
+ UriQuerySignature,
+ QuotedSpacedAssignment,
+ CurlUserColon,
+ 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;