Is there an existing issue for this?
This is the still-unresolved core of #7569 ([BUG] ThreadSafeNucleiEngine accumulates memory across ExecuteNucleiWithOptsCtx calls), which was closed. The parser-sharing / metadata-cache work in #7659 (fix(lib): avoid caching engine state in shared parsers) and #7608 (feat(lib): reuse metadata cache across thread-safe scans) reduced re-parsing overhead, but the compiled template store is still rebuilt per call, so peak heap still grows with concurrency. Filing this to capture the remaining memory scaling with a profile and to ask whether our usage pattern is the intended one.
Current Behavior
We embed Nuclei as a library (the lib SDK) in a long-lived scanning service. We want to run only the templates relevant to each host, and because a single Execute run applies one template set to all of its targets, per-host template selection means fanning out into per-host calls.
Targets are partitioned by protocol class (http / dns / network / …); each class is processed sequentially with its own ThreadSafeNucleiEngine. Within a class there are two phases against that one engine:
- A single fingerprint pre-scan — one
ExecuteNucleiWithOptsCtx over all of the class's hosts (host-sprayed, narrow tech/network tag filter) to learn what each host runs.
- A concurrent per-host fan-out — for each host, one
ExecuteNucleiWithOptsCtx with that host's fingerprinted tag subset, run through a bounded worker pool (up to BulkSize concurrent), all sharing the class engine.
// One engine PER protocol class (classes run sequentially).
eng, _ := nuclei.NewThreadSafeNucleiEngineCtx(ctx /* Options below */)
// Phase 1 — one pre-scan call over ALL of the class's hosts:
eng.ExecuteNucleiWithOptsCtx(ctx, allHostsInClass,
nuclei.WithTemplateFilters(nuclei.TemplateFilters{Tags: fingerprintTags}),
nuclei.WithResultCallback(collect),
)
// Phase 2 — a bounded pool of concurrent calls, one per host, each with
// that host's fingerprinted tag subset (this is the memory driver):
eng.ExecuteNucleiWithOptsCtx(ctx, oneHostsTargets,
nuclei.WithTemplateFilters(nuclei.TemplateFilters{Tags: hostTags}),
nuclei.WithResultCallback(collect),
)
Engine Options (host-spray, ≤2 requests per host — a politeness contract we must keep):
ScanStrategy: host-spray
RateLimit: 128 / s
BulkSize: up to 128 # host pool; see "Anything else" — we had to cut this to 16
TemplateThreads: 2 # ≤2 concurrent templates per host
PayloadConcurrency: 1
HeadlessBulkSize: 0
Retries: 0
Timeout: 2s
Container limit 8 GiB, GOMEMLIMIT=5GiB, GOGC=50.
The problem: each concurrent ExecuteNucleiWithOptsCtx call does loader.New(...) + store.Load() and builds/holds its own compiled []*templates.Template (compiled matchers, extractors, and regexes). createEphemeralObjects sets DoNotCache: true (lib/multi.go), so templates.Parse takes the re-parse-and-recompile path (parseFromSource) on every call rather than the cache-hit path. So live heap scales ~linearly with the number of concurrent calls, and over a broad template corpus the process OOMs well before it should.
GOMEMLIMIT cannot contain it — the memory is live/in-use (many concurrent compiled stores), not collectable garbage, so GC thrashes and the container still hits its hard limit and is OOM-killed.
Heap profile (inuse_space) at ~29 concurrent calls, broad corpus — ~76% of live heap is template parse/compile:
parseTemplateNoVerify ........... 460 MB 52% (cum)
reflect.unsafe_New ............ 182 MB 21% (reflection during template YAML unmarshal)
yaml (*parser).scalar ......... 127 MB 14%
regexp.compile ................ 140 MB 16% (cum) (compiled matcher regexes)
http.(*Request).Compile ....... 71 MB 8% (cum)
ExecutorOptions.Copy ............ 62 MB 7%
Every concurrent call holds a private copy of that compiled corpus. Measured scaling on the same workload: ~25 concurrent calls ≈ 1.3 GB heap, ~34 ≈ 2.7 GB, and larger host counts (60–112 concurrent) exceed 8 GiB and OOM-kill.
Expected Behavior
For concurrent, per-target-filtered scans over one engine, live heap should stay ~O(one compiled corpus) regardless of how many calls run concurrently — i.e. the compiled template store should be buildable once, read-only, and shared across concurrent ExecuteNucleiWithOptsCtx calls, with each call's tag filter selecting a subset at dispatch rather than compiling its own store.
Alternatively: documented guidance on the intended way to run many concurrent, per-host-filtered scans over a single engine without per-call compiled-store duplication.
Steps To Reproduce
Self-contained reproducer — one shared ThreadSafeNucleiEngine, N concurrent ExecuteNucleiWithOptsCtx calls against a local httptest server, each loading the http/ corpus with a per-call tag filter. Peak HeapInuse is sampled and scales with N.
Run: go run . -templates /path/to/nuclei-templates/http -n 32 (sweep -n).
package main
import (
"context"
"flag"
"fmt"
"net/http"
"net/http/httptest"
"runtime"
"sync"
"time"
nuclei "github.com/projectdiscovery/nuclei/v3/lib"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
)
func main() {
tmpl := flag.String("templates", "", "path to a nuclei-templates dir (e.g. .../nuclei-templates/http)")
n := flag.Int("n", 32, "number of concurrent ExecuteNucleiWithOptsCtx calls")
tag := flag.String("tag", "tech", "per-call template tag filter")
flag.Parse()
// Local target so no external hosts are needed; WP-flavored so http templates evaluate.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Server", "Apache/2.4")
w.Header().Set("X-Powered-By", "PHP/8.2")
fmt.Fprint(w, `<!doctype html><html><head><meta name="generator" content="WordPress 6.4"></head><body>wp-content wp-includes readme.txt</body></html>`)
}))
defer srv.Close()
eng, err := nuclei.NewThreadSafeNucleiEngineCtx(context.Background())
if err != nil {
panic(err)
}
defer eng.Close()
// Peak HeapInuse sampler.
var mu sync.Mutex
var peak uint64
stop := make(chan struct{})
go func() {
t := time.NewTicker(50 * time.Millisecond)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
var m runtime.MemStats
runtime.ReadMemStats(&m)
mu.Lock()
if m.HeapInuse > peak {
peak = m.HeapInuse
}
mu.Unlock()
}
}
}()
// Fan out N concurrent calls, each loading the corpus + a per-call tag filter.
var wg sync.WaitGroup
for i := 0; i < *n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = eng.ExecuteNucleiWithOptsCtx(context.Background(), []string{srv.URL},
nuclei.WithTemplatesOrWorkflows(nuclei.TemplateSources{Templates: []string{*tmpl}}),
nuclei.WithTemplateFilters(nuclei.TemplateFilters{Tags: []string{*tag}}),
nuclei.WithResultCallback(func(*output.ResultEvent) {}),
)
}()
}
wg.Wait()
close(stop)
mu.Lock()
p := peak
mu.Unlock()
fmt.Printf("N=%-3d peak HeapInuse=%d MiB\n", *n, p/1024/1024)
}
Running the reproducer while sweeping N (the number of concurrent ExecuteNucleiWithOptsCtx calls) prints the peak HeapInuse at each concurrency level — see the Relevant log output section below. Observed on v3.11.x (http/ corpus ≈ 10.7k templates; -tag tech, so only ~6 templates actually fire — yet each concurrent call independently loads/compiles its own store from the corpus directory): a single call holds ~300 MiB; heap then grows with concurrency (≈250–300 MiB per additional in-flight call) until GC pressure caps it near the top. By N=16–32 it is already in the 5–6.5 GiB range — over an 8 GiB container once RSS overhead is added — and larger host counts OOM-kill. A pprof inuse_space at that point is ~76% template parse/compile (parseTemplateNoVerify dominant). (This is a slightly amplified case: it passes the whole http/ directory as each call's template source; our production path passes a fingerprint-filtered subset per host, so the per-call store is smaller — but it scales with concurrency the same way.)
Relevant log output
Peak HeapInuse from the reproducer above, sweeping the number of concurrent ExecuteNucleiWithOptsCtx calls (v3.11.x, http/ corpus, -tag tech):
N=1 peak HeapInuse= 309 MiB
N=2 peak HeapInuse= 557 MiB
N=4 peak HeapInuse=1049 MiB
N=8 peak HeapInuse=1687 MiB
N=16 peak HeapInuse=4891 MiB
N=24 peak HeapInuse=6238 MiB
N=32 peak HeapInuse=6571 MiB
In our production runtime, the Go memory sampler over one such run shows heap_inuse climbing to ~6.6–7.3 GB with NumGC in the thousands (GC thrashing on live data) before the container is OOM-killed at the 8 GiB cgroup limit.
Environment
- Nuclei:
v3.11.x used as a library (lib SDK), ThreadSafeNucleiEngine.
- Go: 1.2x,
linux/amd64 and linux/arm64.
- Runtime: containerized, 8 GiB limit,
GOMEMLIMIT=5GiB, GOGC=50.
- Templates: broad corpus (network + http), fingerprint-filtered per host.
Anything else?
Root cause as we understand it (and why sharing the store is non-trivial): the cache-hit path in templates.Parse shallow-copies the cached template and then re-stamps per-call ExecutorOptions (Output writer, rate limiter, interactsh) onto the request objects via Request.UpdateOptions → ExecutorOptions.ApplyNewEngineOptions. Under concurrent calls with distinct per-call Output writers, that mutates shared request state and misroutes findings — which is exactly why DoNotCache: true is set for the thread-safe path. So the per-call recompile is a correctness workaround, and the memory cost is its consequence. A safe shared store would need the per-call output/rate-limiter resolved at dispatch (or the request slices/options copied cheaply) rather than stamped onto shared compiled objects, plus tag filtering moved from load-time to dispatch-time over one preloaded store.
Prior history on this exact problem. We reported the same class of issue in nuclei #7569 ([BUG] ThreadSafeNucleiEngine accumulates memory across ExecuteNucleiWithOptsCtx calls, now closed). ProjectDiscovery addressed parts of it with #7659 (fix(lib): avoid caching engine state in shared parsers) and #7608 (feat(lib): reuse metadata cache across thread-safe scans) — both merged, and both helped by sharing the parser and metadata index across calls. But the compiled store is still rebuilt per call (DoNotCache: true), so peak heap still scales with concurrency, which is why we're re-raising it here with a profile.
Related recompilation/leak sources we've reported in the ecosystem (they show up in the profile above and under high concurrency):
Questions for the maintainers:
- Is running many concurrent
ExecuteNucleiWithOptsCtx calls with per-target WithTemplateFilters over one shared engine a supported/intended pattern for per-host-filtered scanning at scale — or is there a better API for "different template subset per target"?
- Is
DoNotCache: true per call the intended long-term tradeoff for thread-safe concurrent executes, or is a safely-shareable compiled store on the roadmap (dispatch-time filtering / copy-on-write request options)?
- If the fan-out is not the recommended shape, what is — a single
Execute over all targets with the union of templates (accepting over-scan), or something else we're missing?
Is there an existing issue for this?
This is the still-unresolved core of #7569 ([BUG] ThreadSafeNucleiEngine accumulates memory across
ExecuteNucleiWithOptsCtxcalls), which was closed. The parser-sharing / metadata-cache work in #7659 (fix(lib): avoid caching engine state in shared parsers) and #7608 (feat(lib): reuse metadata cache across thread-safe scans) reduced re-parsing overhead, but the compiled template store is still rebuilt per call, so peak heap still grows with concurrency. Filing this to capture the remaining memory scaling with a profile and to ask whether our usage pattern is the intended one.Current Behavior
We embed Nuclei as a library (the
libSDK) in a long-lived scanning service. We want to run only the templates relevant to each host, and because a singleExecuterun applies one template set to all of its targets, per-host template selection means fanning out into per-host calls.Targets are partitioned by protocol class (http / dns / network / …); each class is processed sequentially with its own
ThreadSafeNucleiEngine. Within a class there are two phases against that one engine:ExecuteNucleiWithOptsCtxover all of the class's hosts (host-sprayed, narrowtech/networktag filter) to learn what each host runs.ExecuteNucleiWithOptsCtxwith that host's fingerprinted tag subset, run through a bounded worker pool (up toBulkSizeconcurrent), all sharing the class engine.Engine
Options(host-spray, ≤2 requests per host — a politeness contract we must keep):Container limit 8 GiB,
GOMEMLIMIT=5GiB,GOGC=50.The problem: each concurrent
ExecuteNucleiWithOptsCtxcall doesloader.New(...) + store.Load()and builds/holds its own compiled[]*templates.Template(compiled matchers, extractors, and regexes).createEphemeralObjectssetsDoNotCache: true(lib/multi.go), sotemplates.Parsetakes the re-parse-and-recompile path (parseFromSource) on every call rather than the cache-hit path. So live heap scales ~linearly with the number of concurrent calls, and over a broad template corpus the process OOMs well before it should.GOMEMLIMITcannot contain it — the memory is live/in-use (many concurrent compiled stores), not collectable garbage, so GC thrashes and the container still hits its hard limit and is OOM-killed.Heap profile (
inuse_space) at ~29 concurrent calls, broad corpus — ~76% of live heap is template parse/compile:Every concurrent call holds a private copy of that compiled corpus. Measured scaling on the same workload: ~25 concurrent calls ≈ 1.3 GB heap, ~34 ≈ 2.7 GB, and larger host counts (60–112 concurrent) exceed 8 GiB and OOM-kill.
Expected Behavior
For concurrent, per-target-filtered scans over one engine, live heap should stay ~
O(one compiled corpus)regardless of how many calls run concurrently — i.e. the compiled template store should be buildable once, read-only, and shared across concurrentExecuteNucleiWithOptsCtxcalls, with each call's tag filter selecting a subset at dispatch rather than compiling its own store.Alternatively: documented guidance on the intended way to run many concurrent, per-host-filtered scans over a single engine without per-call compiled-store duplication.
Steps To Reproduce
Self-contained reproducer — one shared
ThreadSafeNucleiEngine,NconcurrentExecuteNucleiWithOptsCtxcalls against a localhttptestserver, each loading thehttp/corpus with a per-call tag filter. PeakHeapInuseis sampled and scales withN.Run:
go run . -templates /path/to/nuclei-templates/http -n 32(sweep-n).Running the reproducer while sweeping
N(the number of concurrentExecuteNucleiWithOptsCtxcalls) prints the peakHeapInuseat each concurrency level — see the Relevant log output section below. Observed onv3.11.x(http/corpus ≈ 10.7k templates;-tag tech, so only ~6 templates actually fire — yet each concurrent call independently loads/compiles its own store from the corpus directory): a single call holds ~300 MiB; heap then grows with concurrency (≈250–300 MiB per additional in-flight call) until GC pressure caps it near the top. ByN=16–32it is already in the 5–6.5 GiB range — over an 8 GiB container once RSS overhead is added — and larger host counts OOM-kill. Approfinuse_spaceat that point is ~76% template parse/compile (parseTemplateNoVerifydominant). (This is a slightly amplified case: it passes the wholehttp/directory as each call's template source; our production path passes a fingerprint-filtered subset per host, so the per-call store is smaller — but it scales with concurrency the same way.)Relevant log output
Peak
HeapInusefrom the reproducer above, sweeping the number of concurrentExecuteNucleiWithOptsCtxcalls (v3.11.x,http/corpus,-tag tech):In our production runtime, the Go memory sampler over one such run shows
heap_inuseclimbing to ~6.6–7.3 GB withNumGCin the thousands (GC thrashing on live data) before the container is OOM-killed at the 8 GiB cgroup limit.Environment
v3.11.xused as a library (libSDK),ThreadSafeNucleiEngine.linux/amd64andlinux/arm64.GOMEMLIMIT=5GiB,GOGC=50.Anything else?
Root cause as we understand it (and why sharing the store is non-trivial): the cache-hit path in
templates.Parseshallow-copies the cached template and then re-stamps per-callExecutorOptions(Output writer, rate limiter, interactsh) onto the request objects viaRequest.UpdateOptions→ExecutorOptions.ApplyNewEngineOptions. Under concurrent calls with distinct per-callOutputwriters, that mutates shared request state and misroutes findings — which is exactly whyDoNotCache: trueis set for the thread-safe path. So the per-call recompile is a correctness workaround, and the memory cost is its consequence. A safe shared store would need the per-call output/rate-limiter resolved at dispatch (or the request slices/options copied cheaply) rather than stamped onto shared compiled objects, plus tag filtering moved from load-time to dispatch-time over one preloaded store.Prior history on this exact problem. We reported the same class of issue in nuclei #7569 ([BUG] ThreadSafeNucleiEngine accumulates memory across
ExecuteNucleiWithOptsCtxcalls, now closed). ProjectDiscovery addressed parts of it with #7659 (fix(lib): avoid caching engine state in shared parsers) and #7608 (feat(lib): reuse metadata cache across thread-safe scans) — both merged, and both helped by sharing the parser and metadata index across calls. But the compiled store is still rebuilt per call (DoNotCache: true), so peak heap still scales with concurrency, which is why we're re-raising it here with a profile.Related recompilation/leak sources we've reported in the ecosystem (they show up in the profile above and under high concurrency):
regexp.compilein the profile.ConnReadNleaks a goroutine per context-cancelled read (unbuffered chan inExecFuncWithTwoReturns); fix proposed (issue + PR, open) — a leak we hit under high concurrency.Questions for the maintainers:
ExecuteNucleiWithOptsCtxcalls with per-targetWithTemplateFiltersover one shared engine a supported/intended pattern for per-host-filtered scanning at scale — or is there a better API for "different template subset per target"?DoNotCache: trueper call the intended long-term tradeoff for thread-safe concurrent executes, or is a safely-shareable compiled store on the roadmap (dispatch-time filtering / copy-on-write request options)?Executeover all targets with the union of templates (accepting over-scan), or something else we're missing?