@@ -7,7 +7,10 @@ package waf
77
88import (
99 "encoding/json"
10+ "fmt"
1011 "io"
12+ "runtime"
13+ "runtime/debug"
1114 "strconv"
1215 "testing"
1316
@@ -1683,3 +1686,73 @@ func Test_PerRouteConfigBlocksRequest(t *testing.T) {
16831686
16841687 wafPlugin .OnStreamComplete ()
16851688}
1689+
1690+ // Verifies that OnDestroy() keeps heap growth bounded across
1691+ // config reloads by releasing Coraza's memoized regex patterns.
1692+ func Test_OnDestroyReleasesMemory (t * testing.T ) {
1693+ const (
1694+ reloads = 30
1695+ rulesPerWAF = 50
1696+ )
1697+
1698+ ruleID := 1
1699+ makeDirectives := func () []string {
1700+ directives := []string {"SecRuleEngine On" }
1701+ for range rulesPerWAF {
1702+ directives = append (directives , fmt .Sprintf (
1703+ `SecRule REQUEST_URI "@rx ^/reload/%d/path$" "id:%d,phase:1,pass,nolog"` , ruleID , ruleID ,
1704+ ))
1705+ ruleID ++
1706+ }
1707+ return directives
1708+ }
1709+
1710+ heapAlloc := func () uint64 {
1711+ // GC twice then scavenge: the first GC collects most objects, the second
1712+ // collects anything promoted or finalized during the first pass, and
1713+ // FreeOSMemory forces the runtime to return freed pages to the OS so that
1714+ // HeapAlloc reflects only genuinely live allocations.
1715+ runtime .GC ()
1716+ runtime .GC ()
1717+ debug .FreeOSMemory ()
1718+ var m runtime.MemStats
1719+ runtime .ReadMemStats (& m )
1720+ return m .HeapAlloc
1721+ }
1722+
1723+ baseline := heapAlloc ()
1724+
1725+ for range reloads {
1726+ // Use a fresh controller per reload so gomock state doesn't accumulate
1727+ // and skew the heap measurement.
1728+ ctrl := gomock .NewController (t )
1729+
1730+ mainConfig , err := json .Marshal (map [string ]any {"directives" : makeDirectives (), "mode" : "FULL" })
1731+ require .NoError (t , err )
1732+ perRouteConfig , err := json .Marshal (map [string ]any {"directives" : makeDirectives (), "mode" : "FULL" })
1733+ require .NoError (t , err )
1734+
1735+ configHandle := mocks .NewMockHttpFilterConfigHandle (ctrl )
1736+ configHandle .EXPECT ().DefineCounter ("waf_tx_total" ).Return (shared .MetricID (1 ), shared .MetricsSuccess )
1737+ configHandle .EXPECT ().DefineCounter ("waf_tx_blocked" , "authority" , "phase" , "rule_id" ).Return (shared .MetricID (2 ), shared .MetricsSuccess )
1738+
1739+ configFactory := & wafPluginConfigFactory {}
1740+ factory , err := configFactory .Create (configHandle , mainConfig )
1741+ require .NoError (t , err )
1742+ _ , err = configFactory .CreatePerRoute (perRouteConfig )
1743+ require .NoError (t , err )
1744+
1745+ // Simulate Envoy destroying the old factory when a config reload arrives.
1746+ factory .OnDestroy ()
1747+ ctrl .Finish ()
1748+ }
1749+
1750+ const maxGrowthKB = int64 (1024 )
1751+ heapGrowth := (int64 (heapAlloc ()) - int64 (baseline )) / 1024
1752+
1753+ // Without OnDestroy releasing memoized caches: 30 reloads × 2 WAFs × 50 unique
1754+ // regexes leaks ~11+ MB. With Close(), heap growth stays under 1 MB.
1755+ assert .LessOrEqual (t , heapGrowth , maxGrowthKB ,
1756+ "heap grew %d KB after %d reloads, expected ≤ %d KB: OnDestroy must call Close() on all WAF instances to release Coraza's memoized regex cache" ,
1757+ heapGrowth , reloads , maxGrowthKB )
1758+ }
0 commit comments