Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pkg/js/compiler/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ func (c *Compiler) ExecuteWithOptions(program *goja.Program, args *ExecuteArgs,
}
}()

// Propagate the deadline context so that ExecuteProgram (and both
// the pooled and non-pooled paths) can use it for slot acquisition
// and watchdog goroutines. Without this, opts.Context carries no
// deadline and pool slots are held indefinitely by zombie goroutines.
opts.Context = ctx
return ExecuteProgram(program, args, opts)
})
if err != nil {
Expand Down
29 changes: 27 additions & 2 deletions pkg/js/compiler/non-pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package compiler

import (
"sync"
"sync/atomic"

"github.com/Mzack9999/goja"
syncutil "github.com/projectdiscovery/utils/sync"
Expand All @@ -16,8 +17,32 @@ var (

func executeWithoutPooling(p *goja.Program, args *ExecuteArgs, opts *ExecuteOptions) (result goja.Value, err error) {
lazyFixedSgInit()
ephemeraljsc.Add()
defer ephemeraljsc.Done()
// Acquire a pool slot, respecting the execution deadline. Returns
// immediately if the context has already expired.
if err := ephemeraljsc.AddWithContext(opts.Context); err != nil {
return nil, err
}
// Watchdog: release the pool slot if the deadline expires while the
// goroutine is still running (zombie). See executeWithPoolingProgram
// for the full explanation. The atomic.Bool guarantees exactly one
// Done() call between the watchdog and the normal defer path.
var slotReleased atomic.Bool
done := make(chan struct{})
go func() {
select {
case <-opts.Context.Done():
if slotReleased.CompareAndSwap(false, true) {
ephemeraljsc.Done()
}
case <-done:
}
}()
defer func() {
close(done)
if slotReleased.CompareAndSwap(false, true) {
ephemeraljsc.Done()
}
}()
runtime := createNewRuntime()
return executeWithRuntime(runtime, p, args, opts)
}
33 changes: 31 additions & 2 deletions pkg/js/compiler/pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"reflect"
"sync"
"sync/atomic"

"github.com/Mzack9999/goja"
"github.com/Mzack9999/goja_nodejs/console"
Expand Down Expand Up @@ -85,6 +86,7 @@ func executeWithRuntime(runtime *goja.Runtime, p *goja.Program, args *ExecuteArg
opts.Cleanup(runtime)
}
runtime.RemoveContextValue("executionId")
runtime.RemoveContextValue("ctx")
}()

// TODO(dwisiswant0): remove this once we get the RCA.
Expand Down Expand Up @@ -113,6 +115,7 @@ func executeWithRuntime(runtime *goja.Runtime, p *goja.Program, args *ExecuteArg

// inject execution id and context
runtime.SetContextValue("executionId", opts.ExecutionId)
runtime.SetContextValue("ctx", opts.Context)

// execute the script
return runtime.RunProgram(p)
Expand All @@ -139,8 +142,34 @@ func executeWithPoolingProgram(p *goja.Program, args *ExecuteArgs, opts *Execute
lazySgInit()
sgResizeCheck(opts.Context)

pooljsc.Add()
defer pooljsc.Done()
// Acquire a pool slot, respecting the execution deadline. Returns
// immediately if the context has already expired.
if err := pooljsc.AddWithContext(opts.Context); err != nil {
return nil, err
}
// Watchdog: release the pool slot if the deadline expires while the
// goroutine is still running (zombie). ExecFuncWithTwoReturns abandons
// the caller on timeout, but the goroutine keeps running and holds its
// slot via defer. The watchdog ensures the slot is freed at the deadline
// so the pool doesn't starve. The atomic.Bool guarantees exactly one
// Done() call between the watchdog and the normal defer path.
var slotReleased atomic.Bool
done := make(chan struct{})
go func() {
select {
case <-opts.Context.Done():
if slotReleased.CompareAndSwap(false, true) {
pooljsc.Done()
}
case <-done:
}
}()
defer func() {
close(done)
if slotReleased.CompareAndSwap(false, true) {
pooljsc.Done()
}
}()
runtime := gojapool.Get().(*goja.Runtime)
defer gojapool.Put(runtime)
var buff bytes.Buffer
Expand Down
141 changes: 141 additions & 0 deletions pkg/js/compiler/pool_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package compiler

import (
"context"
"sync/atomic"
"testing"
"time"

"github.com/stretchr/testify/require"

syncutil "github.com/projectdiscovery/utils/sync"
)

// TestAddWithContextRespectsDeadline verifies that AddWithContext returns an
// error when the context deadline expires while waiting for a pool slot.
// Before the fix, Add() used context.Background() and would block indefinitely.
func TestAddWithContextRespectsDeadline(t *testing.T) {
pool, err := syncutil.New(syncutil.WithSize(1))
require.NoError(t, err)

// Fill the only slot.
pool.Add()
defer pool.Done()

// Try to acquire with a short deadline — should fail fast, not hang.
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()

start := time.Now()
err = pool.AddWithContext(ctx)
elapsed := time.Since(start)

require.Error(t, err, "AddWithContext should fail when pool is full and deadline expires")
require.Less(t, elapsed, 200*time.Millisecond, "AddWithContext should fail fast after deadline")
}

// TestWatchdogReleasesSlotOnDeadline verifies that the watchdog goroutine
// releases a pool slot when the execution deadline expires, even if the
// worker goroutine is still running (zombie). This is the core fix for
// pool slot starvation: without the watchdog, a zombie goroutine holds its
// slot via defer Done() until its network call eventually times out (or never).
func TestWatchdogReleasesSlotOnDeadline(t *testing.T) {
pool, err := syncutil.New(syncutil.WithSize(1))
require.NoError(t, err)

// Acquire the only slot (simulates a JS execution starting).
pool.Add()

// Set up the watchdog pattern (same as our fix in pool.go / non-pool.go).
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()

var slotReleased atomic.Bool
watchdogDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
if slotReleased.CompareAndSwap(false, true) {
pool.Done()
}
case <-watchdogDone:
}
}()
defer func() {
close(watchdogDone)
if slotReleased.CompareAndSwap(false, true) {
pool.Done()
}
}()

// Wait for the deadline to fire and the watchdog to release the slot.
<-ctx.Done()
time.Sleep(20 * time.Millisecond)

// A new execution should be able to acquire the slot, even though the
// "zombie" never called Done() itself.
freshCtx, freshCancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer freshCancel()
require.NoError(t, pool.AddWithContext(freshCtx),
"slot acquisition should succeed after watchdog release")
pool.Done()
}

// TestPoolExhaustionRecovery demonstrates the complete starvation/recovery
// cycle. All pool slots are filled with zombie goroutines that block well
// beyond their deadline. The watchdog pattern frees the slots when the
// deadlines expire, allowing subsequent executions to proceed.
//
// Without the fix, the pool stays permanently exhausted and every subsequent
// AddWithContext call fails (or Add() blocks forever).
func TestPoolExhaustionRecovery(t *testing.T) {
const poolSize = 3
pool, err := syncutil.New(syncutil.WithSize(poolSize))
require.NoError(t, err)

// Fill every slot with a "zombie" that blocks for 10s but has a 100ms
// deadline. The watchdog should free each slot after ~100ms.
for i := range poolSize {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()

require.NoError(t, pool.AddWithContext(ctx), "initial slot acquisition %d", i)

var released atomic.Bool
done := make(chan struct{})

// Watchdog: release slot when deadline expires.
go func() {
select {
case <-ctx.Done():
if released.CompareAndSwap(false, true) {
pool.Done()
}
case <-done:
}
}()

// Zombie worker: blocks for 10s simulating a hung network call.
go func() {
defer func() {
close(done)
if released.CompareAndSwap(false, true) {
pool.Done()
}
}()
time.Sleep(10 * time.Second)
}()
}

// Pool is fully saturated. Wait for all deadlines to expire.
time.Sleep(200 * time.Millisecond)

// All slots should now be free. Acquire and release each one.
for i := range poolSize {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
require.NoError(t, pool.AddWithContext(ctx),
"post-recovery slot acquisition %d/%d (pool still starved)", i+1, poolSize)
pool.Done()
}
}
Loading