fix: bound exitCondition regex matches on the caller thread - #455
Conversation
closes #450 Pathological exitCondition patterns no longer block the evaluator or leak ForkJoinPool.commonPool threads; all triggers share a deadline CharSequence helper with substring fallback.
fdelbrayelle
left a comment
There was a problem hiding this comment.
The approach is sound: the deadline CharSequence runs on the caller thread, so the commonPool leak is gone and the bound is real. It meets #450. One regression and a few smaller points.
Needs a fix
1. StackOverflowError now escapes evaluate() in Ruby and Perl.
ExitConditionRegex.java:46-52 (find(Pattern, String, Duration)). Patterns like (a|b)* on a long haystack overflow the stack inside java.util.regex. The old CompletableFuture.supplyAsync(...).get() wrapped that error in an ExecutionException, and catch (Exception) fell back to substring. Now only RegexTimeoutException is caught. The haystack is script output, so a user-supplied condition can trigger it.
Fix: use the same fallback as the timeout.
} catch (RegexTimeoutException | StackOverflowError e) {
return haystack.contains(pattern.pattern());
}Add a test with (a|b)*c against a 100k character haystack.
Suggestions
2. The evaluator thread can still block for 5s per poll (ExitConditionRegex.java:21, TIMEOUT). A pathological pattern holds the thread for 5s on every poll. Consider ~1s (exit conditions run against a few KB of output), or document the limit.
3. Timeout tests are timing-sensitive (ExitConditionRegexTest catastrophicPattern_*, Perl ScriptTriggerConditionTest / CommandsTriggerConditionTest). assertTimeoutPreemptively(timeout.multipliedBy(2)) leaves a 500ms margin. Perl also asserts elapsedMs >= 4000 against a 10s limit. Use a 3x to 4x margin, or assert only the fallback result and a lower bound on elapsed time.
4. Only Perl and the helper have timeout-path tests. Add one catastrophic-pattern assertion in Ruby and one in a cached-pattern module such as Bun or R.
5. TimeoutCharSequence.subSequence restarts the counter (:88-91). Harmless today. Optionally share the counter.
Nits
6. Unrelated formatting churn: import reordering in node/python/r triggers, record ExtractedFailure reflow, @CsvSource and assertThat reflows in Perl tests, the )@Plugin( fix in ruby/CommandsTrigger, joined EXIT_CONDITION_PATTERN lines. Consider a separate commit.
7. Javadoc on ExitConditionRegex is multi-line. One short line is enough.
8. Perl imports ExitConditionRegexTest only for a static helper. A small shared test support class would be cleaner.
Catch StackOverflowError with the same substring fallback as timeouts, drop the match budget to 1s, and broaden timeout-path coverage.
Keep only the functional ExitConditionRegex changes so the PR stays easy to review.
|
Thanks for the review @fdelbrayelle , addressed in the follow-up commits.
|
fdelbrayelle
left a comment
There was a problem hiding this comment.
Review: changes needed
The helper is sound: caller thread, deadline enforced through a CharSequence wrapper, PatternSyntaxException and StackOverflowError handled, and the Ruby/Perl commonPool leak is gone. But the migration is incomplete.
Must fix
-
jbang still uses the unguarded match (ReDoS stays open):
plugin-script-jbang/src/main/java/io/kestra/plugin/scripts/jbang/ScriptTrigger.java:265plugin-script-jbang/src/main/java/io/kestra/plugin/scripts/jbang/CommandsTrigger.java:237
Replace
Pattern.compile(cond).matcher(haystack).find()withExitConditionRegex.find(cond, haystack). -
lua is not migrated (landed in #447, already on
main):plugin-script-lua/src/main/java/io/kestra/plugin/scripts/lua/ScriptTrigger.java:241plugin-script-lua/src/main/java/io/kestra/plugin/scripts/lua/CommandsTrigger.java:246
Use
ExitConditionRegex.find(conditionPattern(cond), haystack). -
Tests: add one catastrophic-pattern timeout test for jbang and one for lua. I also found no timeout tests for Bun
CommandsTrigger, R, PowerShell, .NET or Node.
Should fix
- PR body says matching stops after 5s, but
TIMEOUTis 1s. Ruby and Perl go from a 5s to a 1s budget, so the body should say so. ExitConditionRegex.java:48falls back to substring matching silently. Log a warn naming theexitCondition(also for the invalid-regex fallback).- Too many comments and javadoc blocks in
ExitConditionRegex.java,ExitConditionRegexTestSupport.javaand the Bun test. Keep one short line at most. - Go
ScriptTriggerTestre-implementsexitConditionMatchesinstead of calling productionmatchesCondition. Make it package-private and test it directly. ExitConditionRegexTestSupport.assertNoRegexOnCommonPoolusesThread.sleep(200)and a global thread scan, which can be flaky. The caller-thread design makes it mostly redundant, so consider dropping it.
Nit
CONDITION_PATTERNSin the Bun, R and Lua triggers is an unbounded static cache. Not a regression.
fdelbrayelle
left a comment
There was a problem hiding this comment.
Review: no blocking findings
The ReDoS fix meets issue #450. Regex now runs on the caller thread with a 1s deadline, so the commonPool leak is gone. Invalid regex and stack overflow fall back to a substring match. I did not run builds or tests.
Suggestions (non-blocking)
- Slow and possibly flaky tests.
CommandsTriggerConditionTest.java:15and the same test in Bun, Deno, .NET, jbang, Lua, Ruby, R and Go repeatExitConditionRegexTest. They use the real 1s deadline and a>= 500mscheck. Keep one wiring test per trigger, make the deadline injectable, and drop the elapsed-time lower bound. - Log noise.
ExitConditionRegex.java:84-87: a badexitConditionburns 1s CPU and logs a WARN on every poll. Cache known-bad patterns, or WARN once and then DEBUG. - Unrelated change.
PythonTest.java:262andScriptTest.java:306switchgetNano()totoNanos(). It is a valid flaky-test fix, but please mention it in the PR description or split it out. - Deadline limit. The deadline is checked every 1024
charAtcalls, so a pattern that does not read characters is not interrupted. Acceptable and consistent with coreRegexUtils, but worth a note in the PR. - Comments. Remove the leftover Javadoc in dotnet
ScriptTriggerTest.java:17and the one onExitConditionRegex.java:12.
fdelbrayelle
left a comment
There was a problem hiding this comment.
Review: looks good, suggestions only
No blocking issues. The core fix is sound:
- Regex matching runs on the caller thread with a 1s deadline, so nothing is left on
ForkJoinPool.commonPool. StackOverflowErrorfalls back to substring matching.- All 27 call sites go through
ExitConditionRegex. - Issue #450 requirements are met. No security or performance findings.
Check first
- The PR shows "Checks 0/1 passed". Please confirm it is green or explain the failure before merge.
Suggestions
- The wiring tests use
mockStatic, so they only prove each trigger calls the helper. Consider one non-mocked(.*a){20}$test per module family, wrapped inassertTimeoutPreemptively. - The
Duration.toNanos()fixes inNodeTest,PythonTest,ScriptTestandAbstractBashTestare correct but unrelated. A separate PR would be easier to review and revert. - Go
matchesConditionchanged fromprivateto package-private only for tests. Low impact, and it matches other modules. - A pathological pattern still blocks the evaluating thread for 1s per poll. This is documented in the PR body. Optionally cache the compiled
Pattern, or skip regex after the first timeout.
Nits
- Bun and R use
catch (Exception invalidRegex)aroundExitConditionRegex.find. CatchPatternSyntaxExceptiononly, or compile in thetryand callfindoutside it. timeoutMatchesTheDocumentedMatchBudgetonly asserts a constant equals itself. It can be dropped.matchesConditionandbuildHaystackare duplicated across about 27 triggers. A follow-up could move them into a shared base class.
|
Edition: OSS | Docker tag: develop (image QA summary
Observation window: about 6 minutes, ReDoS CPU check
Note on
|
closes #450
Pathological
exitConditionpatterns no longer block the evaluator or leakForkJoinPool.commonPoolthreads; all triggers share a deadlineCharSequencehelper with substring fallback.What changes are being made and why?
ScriptTrigger/CommandsTriggercompile userexitConditionas a regex and match it on every poll. Catastrophic backtracking never throws, so:Matcher.find()on the evaluating threadCompletableFuturewith a 5s timeout, but the match kept running oncommonPoolat 100% CPU after each pollThis PR adds
ExitConditionRegexinplugin-script(same deadline-CharSequenceapproach as Kestra coreRegexUtils, shimmed while we target 1.3.x). Every trigger uses it. Matching runs on the caller thread with a 1s deadline, then falls back to substring on timeout. Ruby/Perl move from a 5s to a 1s matching budget and no longer submit regex work tocommonPool.Additional scope and deadline limitation
Duration.toNanos()instead ofgetNano(). The latter returns only the fractional-second component and can incorrectly report zero for a positive whole-second duration. These are unrelated flaky-test fixes included in this PR.CharSequence.charAtcalls. Regex work that does not read characters (including pattern compilation) is not interrupted by this guard. This matches the approach used by coreRegexUtils; it is not a hard wall-clock limit on every regex operation.How the changes have been QAed?
Unit tests cover the timeout path with
(.*a){20}$(not(a+)+$, which JDK 21+/25 memoizes). Helper, Perl, Ruby, Go, Bun, Deno, .NET, PowerShell, and R condition tests passed.Repro flow (CPU should stay flat after the fix; previously climbed ~1 core per poll):
Watch for ~1 minute:
Expected: trigger does not fire; no rising
ForkJoinPool.commonPool-worker-*threads at 100% CPU.Contributor Checklist