Skip to content

fix(js): interrupt goja runtime on context cancel#7343

Merged
dwisiswant0 merged 9 commits intoprojectdiscovery:devfrom
mikhail5555:bugfix/terminate-goja-runtime-on-ctx-cancel
Apr 16, 2026
Merged

fix(js): interrupt goja runtime on context cancel#7343
dwisiswant0 merged 9 commits intoprojectdiscovery:devfrom
mikhail5555:bugfix/terminate-goja-runtime-on-ctx-cancel

Conversation

@mikhail5555
Copy link
Copy Markdown
Contributor

@mikhail5555 mikhail5555 commented Apr 14, 2026

Proposed changes

Explicitly propagate context to goja runtime functions.
Based on context, emit an runtime.Interrupt https://github.com/dop251/goja?tab=readme-ov-file#interrupting, so that the runtime does not stay running in the background, even if the function around it has returned.

Proof

image

The tests added show that on context.cancel the runtime terminates (This was not the case before, removing the runtime.Interrupt(ctx.Err()) will make the tests fail.)

Checklist

  • Pull request is created against the dev branch
  • All checks passed (lint, unit/integration/regression tests etc.) with my changes
  • I have added tests that prove my fix is effective or that my feature works
  • I have added necessary documentation (if appropriate)

Summary by CodeRabbit

  • Bug Fixes

    • Improved timeout enforcement so JS executions reliably terminate and resources are cleaned up when contexts expire.
  • Refactor

    • Unified explicit context propagation across compilation, pooled, and non‑pooled runtime paths for consistent cancellation and simpler cleanup.
  • Tests

    • Added tests verifying pooled and non‑pooled executions are canceled on context deadline; removed several legacy/starvation/watchdog tests.

@auto-assign auto-assign bot requested a review from Mzack9999 April 14, 2026 15:48
@mikhail5555 mikhail5555 changed the title Interrupt goja runtime on context cancel fix(goja): Interrupt goja runtime on context cancel Apr 14, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 14, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

JS execution removed the Context field from ExecuteOptions. Compiler and ExecuteProgram now accept an explicit ctx context.Context and thread it through pooled and non‑pooled paths, interrupting runtimes on ctx cancellation and simplifying slot/release coordination.

Changes

Cohort / File(s) Summary
Compiler API & tests
pkg/js/compiler/compiler.go, pkg/js/compiler/compiler_test.go
Removed Context from ExecuteOptions; ExecuteWithOptions now takes ctx context.Context as first param. Tests updated to pass t.Context() and added timeout tests for pooled and non‑pooled execution.
Non‑pooled execution
pkg/js/compiler/non-pool.go
executeWithoutPooling now accepts ctx, uses ephemeraljsc.AddWithContext(ctx), always defers ephemeraljsc.Done(), and passes ctx into executeWithRuntime. Watchdog/zombie coordination removed.
Pooled execution & pool logic
pkg/js/compiler/pool.go, pkg/js/compiler/pool_test.go, pkg/js/compiler/pool_starvation_test.go (removed)
ExecuteProgram signature gained ctx; pool admission/resize checks use ctx and AddWithContext. Execution runs in a goroutine selecting on ctx.Done() vs completion; on cancel it calls runtime.Interrupt. Previous watchdog/atomic slot-release logic and some starvation tests were removed.
Protocol callers / JS orchestration
pkg/protocols/code/code.go, pkg/protocols/javascript/js.go
Call sites updated to pass explicit contexts (e.g., input.Context(), target.Context() or context.Background()) as first arg to ExecuteWithOptions; removed Context from options at call sites. Minor formatting changes.

Sequence Diagram(s)

sequenceDiagram
    participant Caller as Caller
    participant Compiler as Compiler
    participant Pool as Pool
    participant Runtime as Runtime

    Caller->>Compiler: ExecuteWithOptions(ctx, program, args, opts)
    Compiler->>Pool: ExecuteProgram(ctx, program, args, opts)
    Pool->>Pool: Acquire slot using ctx (AddWithContext / sgResizeCheck)
    Pool->>Runtime: Get or create runtime
    Pool->>Runtime: run program in goroutine
    Runtime->>Pool: send result or panic-as-error
    alt ctx cancelled before completion
        Pool->>Runtime: Interrupt(ctx.Err())
        Runtime-->>Caller: return error (deadline/cancel)
        Pool->>Pool: Return runtime to pool / Done()
    else normal completion
        Runtime-->>Caller: return value
        Pool->>Pool: Return runtime to pool / Done()
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

I'm a rabbit with a tiny clock,
I hop through runtimes, tick-tock-tock,
When ctx expires I give a tap—
Interrupt! Return! No orphaned gap,
All slots tidy, no more clockwork shock. 🐇

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: interrupting the goja runtime when context is cancelled, which is the core fix across all modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@neo-by-projectdiscovery-dev
Copy link
Copy Markdown

neo-by-projectdiscovery-dev bot commented Apr 14, 2026

Neo - PR Security Review

No security issues found

Hardening Notes
  • The removed tests (TestPoolSlotStarvation, TestWatchdogPreventsStarvation, TestWatchdogReleasesSlotOnDeadline, TestPoolExhaustionRecovery) provided important coverage for pool slot management and context deadline handling
  • Consider whether the removal of these tests reduces confidence in the pool starvation fix - they documented and validated the watchdog pattern behavior

Comment @pdneo help for available commands. · Open in Neo

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/js/compiler/compiler.go (1)

102-121: ⚠️ Potential issue | 🔴 Critical

Normalize optional inputs before calling context.WithTimeoutCause.

Line 103 initializes opts to an empty &ExecuteOptions{} when nil, but line 120 then dereferences opts.TimeoutVariants without checking if it's nil. Since TimeoutVariants is a pointer field, this will panic. Additionally, a nil ctx passed to line 120 will also panic. Ensure both are properly initialized or rejected.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/js/compiler/compiler.go` around lines 102 - 121, In ExecuteWithOptions,
calling context.WithTimeoutCause using opts.TimeoutVariants may panic because
opts.TimeoutVariants can be nil and ctx can be nil; ensure inputs are normalized
before that call by: if ctx == nil set ctx = context.Background(); if opts ==
nil set opts = &ExecuteOptions{} (already done) and if opts.TimeoutVariants ==
nil set opts.TimeoutVariants = &TimeoutVariants{JsCompilerExecutionTimeout:
defaultTimeout} (or return an error if missing); then call
context.WithTimeoutCause(ctx, opts.TimeoutVariants.JsCompilerExecutionTimeout,
ErrJSExecDeadline). Reference ExecuteWithOptions,
ExecuteOptions.TimeoutVariants, context.WithTimeoutCause and ErrJSExecDeadline
when applying the fix.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/js/compiler/pool.go`:
- Around line 157-169: The watchdog goroutine currently calls
gojapool.Put(runtime) (in the ctx.Done and done cases), which can return the
same *goja.Runtime to the pool before the outer cleanup defers run—introduce a
watchdogDone channel, have the watchdog defer close(watchdogDone) and remove all
gojapool.Put(runtime) calls from inside the watchdog; then in the main cleanup
path wait for watchdogDone to be closed before calling gojapool.Put(runtime) so
runtime is only returned after all cleanup defers (and slotReleased/pooljsc
handling) have completed, ensuring runtime, runtime.ClearInterrupt(),
runtime.Interrupt(ctx.Err()), slotReleased.CompareAndSwap, and pooljsc.Done are
synchronized.

---

Outside diff comments:
In `@pkg/js/compiler/compiler.go`:
- Around line 102-121: In ExecuteWithOptions, calling context.WithTimeoutCause
using opts.TimeoutVariants may panic because opts.TimeoutVariants can be nil and
ctx can be nil; ensure inputs are normalized before that call by: if ctx == nil
set ctx = context.Background(); if opts == nil set opts = &ExecuteOptions{}
(already done) and if opts.TimeoutVariants == nil set opts.TimeoutVariants =
&TimeoutVariants{JsCompilerExecutionTimeout: defaultTimeout} (or return an error
if missing); then call context.WithTimeoutCause(ctx,
opts.TimeoutVariants.JsCompilerExecutionTimeout, ErrJSExecDeadline). Reference
ExecuteWithOptions, ExecuteOptions.TimeoutVariants, context.WithTimeoutCause and
ErrJSExecDeadline when applying the fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f9877b83-5790-49d6-903a-1a37d2e8159f

📥 Commits

Reviewing files that changed from the base of the PR and between 56e53cf and bcae377.

📒 Files selected for processing (7)
  • pkg/js/compiler/compiler.go
  • pkg/js/compiler/compiler_test.go
  • pkg/js/compiler/non-pool.go
  • pkg/js/compiler/pool.go
  • pkg/js/compiler/pool_test.go
  • pkg/protocols/code/code.go
  • pkg/protocols/javascript/js.go

Comment thread pkg/js/compiler/pool.go Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
pkg/js/compiler/pool.go (1)

162-177: ⚠️ Potential issue | 🔴 Critical

Don't return the runtime to gojapool from the watchdog.

On cancellation, this makes the same *goja.Runtime reusable before executeWithRuntime and the local cleanup defers have finished. The next checkout can clear the interrupt or overwrite globals while the canceled execution is still unwinding, which reintroduces a real concurrent-reuse/state-leak race.

🛠️ Suggested fix
 	var slotReleased atomic.Bool
 	done := make(chan struct{})
+	watchdogDone := make(chan struct{})
 	go func() {
+		defer close(watchdogDone)
 		select {
 		case <-ctx.Done():
 			runtime.Interrupt(ctx.Err())
-			gojapool.Put(runtime)
 			if slotReleased.CompareAndSwap(false, true) {
 				pooljsc.Done()
 			}
 		case <-done:
-			gojapool.Put(runtime)
 			if slotReleased.CompareAndSwap(false, true) {
 				pooljsc.Done()
 			}
 		}
 	}()
-	defer close(done)
+	defer func() {
+		close(done)
+		<-watchdogDone
+		gojapool.Put(runtime)
+	}()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/js/compiler/pool.go` around lines 162 - 177, The watchdog goroutine must
not return the *goja.Runtime to gojapool while the canceled execution may still
be unwinding; remove the gojapool.Put(runtime) calls from the goroutine that
handles <-ctx.Done() and <-done, so the watchdog only calls
runtime.Interrupt(ctx.Err()) and handles slotReleased/pooljsc.Done signaling;
rely on the existing deferred cleanup in executeWithRuntime (the defer that
returns the runtime) to Put(runtime) once local defers have completed. Ensure
the goroutine still closes done or signals as needed but does not call
gojapool.Put(runtime).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@pkg/js/compiler/pool.go`:
- Around line 162-177: The watchdog goroutine must not return the *goja.Runtime
to gojapool while the canceled execution may still be unwinding; remove the
gojapool.Put(runtime) calls from the goroutine that handles <-ctx.Done() and
<-done, so the watchdog only calls runtime.Interrupt(ctx.Err()) and handles
slotReleased/pooljsc.Done signaling; rely on the existing deferred cleanup in
executeWithRuntime (the defer that returns the runtime) to Put(runtime) once
local defers have completed. Ensure the goroutine still closes done or signals
as needed but does not call gojapool.Put(runtime).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f68bc212-ef42-4e43-b26f-13547832a769

📥 Commits

Reviewing files that changed from the base of the PR and between bcae377 and c278795.

📒 Files selected for processing (2)
  • pkg/js/compiler/non-pool.go
  • pkg/js/compiler/pool.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/js/compiler/non-pool.go

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/js/compiler/pool.go (1)

97-106: ⚠️ Potential issue | 🔴 Critical

Move the panic guard into the worker goroutine.

The recover block at lines 98-106 is registered in the parent goroutine and cannot catch panics from runtime.RunProgram at line 128, which executes in a child goroutine. Go's panic/recover mechanism is goroutine-scoped, so a panic in the child goroutine will bypass this guard entirely and can crash the process.

Proposed fix
 resultChan := make(chan gojaRunResult)
 go func() {
+	defer func() {
+		if ci.IsCI() {
+			return
+		}
+		if r := recover(); r != nil {
+			resultChan <- gojaRunResult{nil, fmt.Errorf("panic: %v", r)}
+		}
+	}()
 	result, err := runtime.RunProgram(p)
 	resultChan <- gojaRunResult{result, err}
 }()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/js/compiler/pool.go` around lines 97 - 106, The current defer recover is
registered in the parent goroutine and won't catch panics from
runtime.RunProgram which runs inside the worker goroutine; move the panic guard
into the worker goroutine closure that calls runtime.RunProgram so it runs in
the same goroutine, keep the ci.IsCI check, and ensure any recovered panic is
converted into the existing err reporting path (e.g., set err or send the error
on the worker error channel) so the panic is propagated/handled rather than
crashing the process.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/js/compiler/pool.go`:
- Around line 132-138: Context deadline cancellation currently only calls
runtime.Interrupt() and then waits on resultChan, which can leave a pool slot
held if a blocking native callback runs outside the VM; restore a slot-only
watchdog so the slot is released immediately on ctx.Done without waiting for
RunProgram to return. Reintroduce a lightweight watchdog goroutine that watches
ctx.Done() and calls the pool slot-release method (the same release/unblock
logic used when RunProgram completes) as soon as the deadline fires, keep
runtime.Interrupt() and the existing resultChan handling for eventual program
termination, and add/update TestWatchdogPreventsStarvation to assert that slots
are freed at deadline even when native callbacks remain blocking. Ensure you
reference the select block using ctx.Done(), runtime.Interrupt, resultChan, and
RunProgram when locating where to add the watchdog and where to assert behavior
in tests.

---

Outside diff comments:
In `@pkg/js/compiler/pool.go`:
- Around line 97-106: The current defer recover is registered in the parent
goroutine and won't catch panics from runtime.RunProgram which runs inside the
worker goroutine; move the panic guard into the worker goroutine closure that
calls runtime.RunProgram so it runs in the same goroutine, keep the ci.IsCI
check, and ensure any recovered panic is converted into the existing err
reporting path (e.g., set err or send the error on the worker error channel) so
the panic is propagated/handled rather than crashing the process.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c64e3c70-a855-43b1-a17a-9fb3f0530810

📥 Commits

Reviewing files that changed from the base of the PR and between c278795 and eabf0ec.

📒 Files selected for processing (2)
  • pkg/js/compiler/non-pool.go
  • pkg/js/compiler/pool.go

Comment thread pkg/js/compiler/pool.go
Comment on lines +132 to +138
select {
case <-ctx.Done():
runtime.Interrupt(ctx.Err())
r := <-resultChan
return r.result, r.err
case r := <-resultChan:
return r.result, r.err
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -t f "\.go$" pkg/js/compiler/

Repository: projectdiscovery/nuclei

Length of output: 272


🏁 Script executed:

wc -l pkg/js/compiler/pool.go pkg/js/compiler/non-pool.go pkg/js/compiler/pool_starvation_test.go

Repository: projectdiscovery/nuclei

Length of output: 188


🏁 Script executed:

sed -n '125,180p' pkg/js/compiler/pool.go

Repository: projectdiscovery/nuclei

Length of output: 1884


🏁 Script executed:

cat -n pkg/js/compiler/non-pool.go

Repository: projectdiscovery/nuclei

Length of output: 1050


🏁 Script executed:

sed -n '60,130p' pkg/js/compiler/pool_starvation_test.go

Repository: projectdiscovery/nuclei

Length of output: 2201


🏁 Script executed:

sed -n '1,306p' pkg/js/compiler/pool.go | grep -i "watchdog" -A 5 -B 5

Repository: projectdiscovery/nuclei

Length of output: 49


🏁 Script executed:

sed -n '140,175p' pkg/js/compiler/pool.go

Repository: projectdiscovery/nuclei

Length of output: 1429


🏁 Script executed:

git log --oneline -20 pkg/js/compiler/pool.go

Repository: projectdiscovery/nuclei

Length of output: 167


🏁 Script executed:

git show HEAD:pkg/js/compiler/pool.go | sed -n '125,180p'

Repository: projectdiscovery/nuclei

Length of output: 1884


🏁 Script executed:

git show eabf0ecf^:pkg/js/compiler/pool.go | sed -n '125,180p'

Repository: projectdiscovery/nuclei

Length of output: 106


🏁 Script executed:

git show eabf0ecf | head -100

Repository: projectdiscovery/nuclei

Length of output: 4277


🏁 Script executed:

git show eabf0ecf -- pkg/js/compiler/pool.go | head -300

Repository: projectdiscovery/nuclei

Length of output: 10371


🏁 Script executed:

git show eabf0ecf -- pkg/js/compiler/pool.go | tail -200

Repository: projectdiscovery/nuclei

Length of output: 6571


🏁 Script executed:

git log --oneline --all pkg/js/compiler/pool.go | head -20

Repository: projectdiscovery/nuclei

Length of output: 1051


🏁 Script executed:

git show eabf0ecf~1:pkg/js/compiler/pool.go 2>/dev/null | head -150

Repository: projectdiscovery/nuclei

Length of output: 49


🏁 Script executed:

git show 3537030e:pkg/js/compiler/pool.go | sed -n '80,200p'

Repository: projectdiscovery/nuclei

Length of output: 3930


🏁 Script executed:

sed -n '67,122p' pkg/js/compiler/pool_starvation_test.go

Repository: projectdiscovery/nuclei

Length of output: 1789


🏁 Script executed:

git show 3537030e:pkg/js/compiler/pool.go | sed -n '140,200p'

Repository: projectdiscovery/nuclei

Length of output: 2213


Watchdog removal reintroduces starvation risk for blocking native callbacks.

The recent refactoring removed the watchdog goroutine (commit 3537030) that released pool slots when context deadlines expired. The current implementation relies on runtime.Interrupt() and waiting for resultChan, but Interrupt() does not preempt code executing outside the VM. If a blocking native callback is running when the context deadline expires, the slot remains held (zombie scenario) until RunProgram() actually returns, not until the deadline fires.

The TestWatchdogPreventsStarvation test (lines 67-122) explicitly validates the scenario being reintroduced: slots must be freed at the deadline to prevent pool exhaustion, even if the goroutine continues running.

Either restore a slot-only watchdog mechanism, or add a regression test demonstrating that every long-running blocking callback respects the context deadline and releases the slot promptly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/js/compiler/pool.go` around lines 132 - 138, Context deadline
cancellation currently only calls runtime.Interrupt() and then waits on
resultChan, which can leave a pool slot held if a blocking native callback runs
outside the VM; restore a slot-only watchdog so the slot is released immediately
on ctx.Done without waiting for RunProgram to return. Reintroduce a lightweight
watchdog goroutine that watches ctx.Done() and calls the pool slot-release
method (the same release/unblock logic used when RunProgram completes) as soon
as the deadline fires, keep runtime.Interrupt() and the existing resultChan
handling for eventual program termination, and add/update
TestWatchdogPreventsStarvation to assert that slots are freed at deadline even
when native callbacks remain blocking. Ensure you reference the select block
using ctx.Done(), runtime.Interrupt, resultChan, and RunProgram when locating
where to add the watchdog and where to assert behavior in tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the case <-time.After(time.Second): to help prevent possible starvation, but from testing after calling runtime.Interrupt(ctx.Err()) the runtime pretty much instantly terminates, meaning that the context cancellation pretty much instantly free up the spot.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mikhail5555, that's a fair point — for pure JS execution (e.g., while(true) {}), runtime.Interrupt() is checked between VM opcodes, so the runtime terminates essentially instantly. The time.After(time.Second) fallback is a practical safety net.

There is one remaining edge case worth being aware of: if a blocking native Go callback (e.g., a long-running network dial) is executing when the context deadline fires, runtime.Interrupt() won't preempt it — the interrupt signal is only checked on the next JS opcode boundary. If that native call takes longer than 1 second, the timeout branch fires and executeWithRuntime returns, freeing the slot. However, the background goroutine (still holding a reference to runtime) continues running. Since executeWithPoolingProgram defers gojapool.Put(runtime), the runtime could be returned to the pool and acquired by another goroutine while the orphaned goroutine is still using it — a potential data race on the runtime.

In practice this is unlikely since:

  1. Most nuclei JS templates don't have blocking native callbacks that take >1 second.
  2. The interrupt is called, so the goroutine should terminate as soon as the native call returns.

But if you want to be safe, one option is to not return the runtime to the pool in the timeout path (i.e., only Put it when the goroutine confirmed completion via resultChan), accepting that the leaked goroutine means that runtime instance is discarded rather than reused. The sync.Pool will create a new one as needed.

That said, the current implementation is a clear improvement over the pre-PR behavior (unbounded zombie runtimes), so it's reasonable to ship this and revisit if the edge case ever surfaces in practice.


🧠 Learnings used
Learnt from: ayuxsec
Repo: projectdiscovery/nuclei PR: 6769
File: pkg/installer/template.go:217-217
Timestamp: 2026-01-15T10:44:22.186Z
Learning: In projectdiscovery/gologger, the Print() function writes to stderr and Silent() writes to stdout. During reviews, ensure code uses Print() for error/output going to stderr and Silent() for stdout; verify the chosen function matches the intended destination and that logs are directed to the correct stream for proper piping/redirects.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
pkg/js/compiler/pool.go (1)

126-130: ⚠️ Potential issue | 🟠 Major

Release the pool slot on cancellation instead of waiting for RunProgram() to finish.

On Line 129 the cancel path still blocks on <-resultChan>, and Lines 164-166 only call pooljsc.Done() after executeWithRuntime() returns. If a registered Go helper is still blocked after the deadline, this execution keeps its slot anyway and can starve the pool despite the canceled context. Reintroduce an early slot-release path on ctx.Done() and keep gojapool.Put(runtime) synchronized with actual runtime cleanup.

Also applies to: 164-166

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/js/compiler/pool.go` around lines 126 - 130, The cancel branch in the
select inside the function using resultChan currently blocks on `<-resultChan>`
and only calls pooljsc.Done()/gojapool.Put(runtime) after executeWithRuntime()
returns, so a canceled execution holds its pool slot; change the ctx.Done() path
to immediately release the pool slot (call pooljsc.Done() or
gojapool.Put(runtime) as appropriate) before waiting for the execution result,
and then wait for result asynchronously (e.g., spawn a goroutine to drain
resultChan and perform runtime cleanup after executeWithRuntime() finishes) so
that executeWithRuntime()/runtime cleanup remains synchronized but the pool slot
is freed on cancellation; reference the select handling resultChan,
runtime.Interrupt(ctx.Err()), executeWithRuntime(), pooljsc.Done(), and
gojapool.Put(runtime).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/js/compiler/pool.go`:
- Around line 138-145: ExecuteProgram dereferences opts immediately but the API
allows nil; guard for opts == nil at the start of ExecuteProgram and normalize
it to a default value (e.g. set opts = &ExecuteOptions{} or call a helper that
returns default options) before any use so subsequent checks like opts.Source
and calls behave correctly; update the function (referencing ExecuteProgram,
opts, and ExecuteOptions) to perform this nil-check/normalization at the top of
the function.

---

Duplicate comments:
In `@pkg/js/compiler/pool.go`:
- Around line 126-130: The cancel branch in the select inside the function using
resultChan currently blocks on `<-resultChan>` and only calls
pooljsc.Done()/gojapool.Put(runtime) after executeWithRuntime() returns, so a
canceled execution holds its pool slot; change the ctx.Done() path to
immediately release the pool slot (call pooljsc.Done() or gojapool.Put(runtime)
as appropriate) before waiting for the execution result, and then wait for
result asynchronously (e.g., spawn a goroutine to drain resultChan and perform
runtime cleanup after executeWithRuntime() finishes) so that
executeWithRuntime()/runtime cleanup remains synchronized but the pool slot is
freed on cancellation; reference the select handling resultChan,
runtime.Interrupt(ctx.Err()), executeWithRuntime(), pooljsc.Done(), and
gojapool.Put(runtime).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2a961b64-21fb-44f2-9631-8d9d77c305ad

📥 Commits

Reviewing files that changed from the base of the PR and between eabf0ec and 7b3cd75.

📒 Files selected for processing (1)
  • pkg/js/compiler/pool.go

Comment thread pkg/js/compiler/pool.go Outdated
Comment on lines +138 to +145
func ExecuteProgram(ctx context.Context, p *goja.Program, args *ExecuteArgs, opts *ExecuteOptions) (result goja.Value, err error) {
if opts.Source == nil {
// not-recommended anymore
return executeWithoutPooling(p, args, opts)
return executeWithoutPooling(ctx, p, args, opts)
}
if !stringsutil.ContainsAny(*opts.Source, exportAsToken, exportToken) {
// not-recommended anymore
return executeWithoutPooling(p, args, opts)
return executeWithoutPooling(ctx, p, args, opts)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard opts == nil in the exported entrypoint.

ExecuteProgram is documented as using default options, but Line 139 dereferences opts immediately. A direct caller passing nil will panic before reaching either execution path. Normalize opts here so the API contract matches the implementation.

Proposed fix
 func ExecuteProgram(ctx context.Context, p *goja.Program, args *ExecuteArgs, opts *ExecuteOptions) (result goja.Value, err error) {
+	if opts == nil {
+		opts = &ExecuteOptions{}
+	}
 	if opts.Source == nil {
 		// not-recommended anymore
 		return executeWithoutPooling(ctx, p, args, opts)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/js/compiler/pool.go` around lines 138 - 145, ExecuteProgram dereferences
opts immediately but the API allows nil; guard for opts == nil at the start of
ExecuteProgram and normalize it to a default value (e.g. set opts =
&ExecuteOptions{} or call a helper that returns default options) before any use
so subsequent checks like opts.Source and calls behave correctly; update the
function (referencing ExecuteProgram, opts, and ExecuteOptions) to perform this
nil-check/normalization at the top of the function.

Copy link
Copy Markdown
Member

@dwisiswant0 dwisiswant0 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure this was already addressed in #7299, what makes you think otherwise? Honestly, you should probably open an issue first before jumping straight to this.

@mikhail5555
Copy link
Copy Markdown
Contributor Author

mikhail5555 commented Apr 15, 2026

I'm pretty sure this was already addressed in #7299, what makes you think otherwise? Honestly, you should probably open an issue first before jumping straight to this.

I don't believe it was addresses sufficiently, the two tests (TestPooledRuntimeTerminatesOnContextExpiry, TestPooledRuntimeTerminatesOnContextExpiry) that i added do not terminate (aka keep running indefinitely) in the existing code.

The runtime is indeed returned to the pool, but is in fact still in use (also see pprof dump in which a poorly crafted nuclei template lead to 1 cpu being utilized all the time in a zombie process).

Having the runtime.Interrupt(ctx.Err()) was necessary to prevent this, but adding it to the WatchDog processes lead to some race-condition which were not easily solved without taken on the structure that i ended up with.

If you prefer i can indeed open up an issue first.

Edit: Just double checked on our cluster, we are still getting the goja (ghost) runtimes on the latests #dev version. So I am very certain this PR is necessary to fully resolve that issue.

@mikhail5555 mikhail5555 requested a review from dwisiswant0 April 15, 2026 08:05
@Mzack9999 Mzack9999 removed their request for review April 15, 2026 16:04
@dwisiswant0
Copy link
Copy Markdown
Member

I don't believe it was addresses sufficiently, the two tests (TestPooledRuntimeTerminatesOnContextExpiry, TestPooledRuntimeTerminatesOnContextExpiry) that i added do not terminate (aka keep running indefinitely) in the existing code.

If you prefer i can indeed open up an issue first.

Yes, please drop a concrete example that goja runtime did not terminate in any realistic execution path while you're at it, e.g., a template + exact nuclei command that hangs here, or a custom runner (via SDK) PoC. It would be a huge help for us to actually reproduce the bug on our end. Thanks!

@mikhail5555
Copy link
Copy Markdown
Contributor Author

I don't believe it was addresses sufficiently, the two tests (TestPooledRuntimeTerminatesOnContextExpiry, TestPooledRuntimeTerminatesOnContextExpiry) that i added do not terminate (aka keep running indefinitely) in the existing code.

If you prefer i can indeed open up an issue first.

Yes, please drop a concrete example that goja runtime did not terminate in any realistic execution path while you're at it, e.g., a template + exact nuclei command that hangs here, or a custom runner (via SDK) PoC. It would be a huge help for us to actually reproduce the bug on our end. Thanks!

Just a double check, do you consider an accidental infinite loop (without requests) not an realistic template? (I would expect that after the timeout has been reached any template should be terminated even if it is poorly crafted). (Say faulty parsing logic that never terminates due to an off by 1 error), because I would say even though the test ofcourse uses an while true loop, nothing is preventing this from happening in an real manually crafted template.

@dwisiswant0
Copy link
Copy Markdown
Member

Just a double check, do you consider an accidental infinite loop (without requests) not an realistic template? (I would expect that after the timeout has been reached any template should be terminated even if it is poorly crafted). (Say faulty parsing logic that never terminates due to an off by 1 error), because I would say even though the test ofcourse uses an while true loop, nothing is preventing this from happening in an real manually crafted template.

Yup, we do consider that situation (even one without any requests) as long as the infinite execution/non-terminating path is reachable in an end-to-end run, it's a legit enough bug for us to care about.

Copy link
Copy Markdown
Member

@dwisiswant0 dwisiswant0 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Tested the patch against the reproduction case in #7348, and can confirm the e2e tests are passing after applying the patch.

Great catch on that!

Thanks for your contributions!

@dwisiswant0 dwisiswant0 changed the title fix(goja): Interrupt goja runtime on context cancel fix(js): interrupt goja runtime on context cancel Apr 16, 2026
@dwisiswant0 dwisiswant0 merged commit ad74c60 into projectdiscovery:dev Apr 16, 2026
21 checks passed
@mikhail5555 mikhail5555 deleted the bugfix/terminate-goja-runtime-on-ctx-cancel branch April 16, 2026 08:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] javascript code in nuclei-template does not terminate after execution is done

2 participants