diff --git a/pkg/js/compiler/compiler_test.go b/pkg/js/compiler/compiler_test.go index 96f3aa4c40..9b2219ae6c 100644 --- a/pkg/js/compiler/compiler_test.go +++ b/pkg/js/compiler/compiler_test.go @@ -148,6 +148,84 @@ func TestExecuteWithRuntimeCleansUpAfterCallbackPanic(t *testing.T) { require.False(t, ok) } +func TestExecuteWithRuntimeCallbackErrorCleansInitializedState(t *testing.T) { + program, err := goja.Compile("", `1`, false) + require.NoError(t, err) + + runtime := createNewRuntime() + args := NewExecuteArgs() + args.Args["arg"] = "value" + args.TemplateCtx["marker"] = "template" + + cleanupCalled := false + callbackErr := fmt.Errorf("callback setup failed") + + _, err = executeWithRuntime(t.Context(), runtime, program, args, &ExecuteOptions{ + ExecutionId: "callback-error-cleanup", + Callback: func(rt *goja.Runtime) error { + require.Equal(t, "value", rt.Get("arg").String()) + _, ok := rt.GetContextValue("ctx") + require.True(t, ok) + require.NoError(t, rt.Set("callbackState", "partial")) + return callbackErr + }, + Cleanup: func(rt *goja.Runtime) { + cleanupCalled = true + _ = rt.GlobalObject().Delete("callbackState") + }, + }, nil) + require.ErrorIs(t, err, callbackErr) + require.True(t, cleanupCalled) + require.Nil(t, runtime.Get("template")) + require.Nil(t, runtime.Get("arg")) + require.Nil(t, runtime.Get("callbackState")) + + _, ok := runtime.GetContextValue("executionId") + require.False(t, ok) + _, ok = runtime.GetContextValue("ctx") + require.False(t, ok) +} + +func TestExecuteWithRuntimePromptInterruptCleansAndAllowsReuse(t *testing.T) { + program, err := goja.Compile("", `while (true) {}`, false) + require.NoError(t, err) + + runtime := createNewRuntime() + args := NewExecuteArgs() + args.Args["arg"] = "value" + args.TemplateCtx["marker"] = "template" + + cleanupCalled := false + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + + _, err = executeWithRuntime(ctx, runtime, program, args, &ExecuteOptions{ + ExecutionId: "prompt-interrupt-cleanup", + Cleanup: func(rt *goja.Runtime) { + cleanupCalled = true + _ = rt.GlobalObject().Delete("cleanupState") + }, + }, nil) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.NotErrorIs(t, err, errRuntimeTerminationTimeout) + require.True(t, cleanupCalled) + require.Nil(t, runtime.Get("template")) + require.Nil(t, runtime.Get("arg")) + + _, ok := runtime.GetContextValue("executionId") + require.False(t, ok) + _, ok = runtime.GetContextValue("ctx") + require.False(t, ok) + + reuseProgram, err := goja.Compile("", `typeof arg === "undefined" && template.marker === undefined`, false) + require.NoError(t, err) + value, err := executeWithRuntime(t.Context(), runtime, reuseProgram, NewExecuteArgs(), &ExecuteOptions{ + ExecutionId: "prompt-interrupt-reuse", + }, nil) + require.NoError(t, err) + require.True(t, value.ToBoolean()) +} + func TestNonPooledRuntimeTerminatesOnContextExpiry(t *testing.T) { timeout := 300 * time.Millisecond @@ -172,6 +250,57 @@ func TestNonPooledRuntimeTerminatesOnContextExpiry(t *testing.T) { } } +func TestNonPooledNormalExecutionReleasesSlot(t *testing.T) { + src := `1` + p, err := SourceAutoMode(src, false) + require.NoError(t, err) + + lazyFixedSgInit() + initialCurrent := ephemeraljsc.Current() + + value, err := executeWithoutPooling(t.Context(), p, NewExecuteArgs(), &ExecuteOptions{Source: &src}) + require.NoError(t, err) + require.Equal(t, int64(1), value.Export()) + require.Equal(t, initialCurrent, ephemeraljsc.Current()) +} + +func TestNonPooledRuntimeAbandonedWhenNativeCallbackStuck(t *testing.T) { + src := `block(); 1` + p, err := SourceAutoMode(src, false) + require.NoError(t, err) + + release := make(chan struct{}) + var releaseOnce sync.Once + releaseOrphan := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(releaseOrphan) + + lazyFixedSgInit() + initialCurrent := ephemeraljsc.Current() + + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + + _, err = executeWithoutPooling(ctx, p, NewExecuteArgs(), &ExecuteOptions{ + Source: &src, + Callback: func(rt *goja.Runtime) error { + return rt.Set("block", func() { + <-release + }) + }, + }) + require.Error(t, err) + require.ErrorIs(t, err, errRuntimeTerminationTimeout) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.GreaterOrEqual(t, ephemeraljsc.Current(), initialCurrent+1, + "abandoned non-pooled runtime must keep its concurrency slot while the orphan is still alive") + + releaseOrphan() + + require.Eventually(t, func() bool { + return ephemeraljsc.Current() == initialCurrent + }, 5*time.Second, 10*time.Millisecond, "non-pooled slot must be released once the orphan goroutine exits") +} + func TestPooledRuntimeTerminatesOnContextExpiry(t *testing.T) { timeout := 300 * time.Millisecond @@ -196,6 +325,80 @@ func TestPooledRuntimeTerminatesOnContextExpiry(t *testing.T) { } } +func TestPooledRuntimeNormalExecutionCleansStateBeforeReuse(t *testing.T) { + src := `Export("ok"); ExportAs("named", arg); true` + p, err := SourceAutoMode(src, false) + require.NoError(t, err) + + runtime := createNewRuntime() + useRuntimePool(t, runtime) + + lazySgInit() + initialCurrent := pooljsc.Current() + + args := NewExecuteArgs() + args.Args["arg"] = "value" + args.TemplateCtx["marker"] = "template" + + cleanupCalled := false + value, err := executeWithPoolingProgram(t.Context(), p, args, &ExecuteOptions{ + Source: &src, + ExecutionId: "pooled-cleanup", + Callback: func(rt *goja.Runtime) error { + require.NoError(t, rt.Set("callbackState", "temporary")) + return nil + }, + Cleanup: func(rt *goja.Runtime) { + cleanupCalled = true + _ = rt.GlobalObject().Delete("callbackState") + }, + }) + require.NoError(t, err) + require.Equal(t, "ok", value.Export()) + require.True(t, cleanupCalled) + require.Equal(t, initialCurrent, pooljsc.Current()) + + require.Nil(t, runtime.Get("template")) + require.Nil(t, runtime.Get("arg")) + require.Nil(t, runtime.Get("callbackState")) + require.Nil(t, runtime.Get(exportToken)) + require.Nil(t, runtime.Get(exportAsToken)) + _, ok := runtime.GetContextValue("executionId") + require.False(t, ok) + _, ok = runtime.GetContextValue("ctx") + require.False(t, ok) +} + +func TestPooledCallbackErrorReleasesSlotAndCleansExportHelpers(t *testing.T) { + src := `ExportAs("named", "value"); true` + p, err := SourceAutoMode(src, false) + require.NoError(t, err) + + runtime := createNewRuntime() + useRuntimePool(t, runtime) + + lazySgInit() + initialCurrent := pooljsc.Current() + callbackErr := fmt.Errorf("callback setup failed") + + _, err = executeWithPoolingProgram(t.Context(), p, NewExecuteArgs(), &ExecuteOptions{ + Source: &src, + Callback: func(rt *goja.Runtime) error { + require.NoError(t, rt.Set("callbackState", "temporary")) + return callbackErr + }, + Cleanup: func(rt *goja.Runtime) { + _ = rt.GlobalObject().Delete("callbackState") + }, + }) + require.ErrorIs(t, err, callbackErr) + require.Equal(t, initialCurrent, pooljsc.Current()) + + require.Nil(t, runtime.Get("callbackState")) + require.Nil(t, runtime.Get(exportToken)) + require.Nil(t, runtime.Get(exportAsToken)) +} + // TestPooledRuntimeAbandonedReleasesSlotOnlyAfterOrphanExits verifies that // when a runtime is abandoned because its goja goroutine outlived the // interrupt grace period, executeWithPoolingProgram does NOT release the @@ -293,6 +496,182 @@ func TestPooledRuntimeAbandonedWhenInterruptStuck(t *testing.T) { } } +func TestSessionAbandonmentSkipsFinalizeCleanupAndNormalRelease(t *testing.T) { + src := `block(); "done"` + p, err := goja.Compile("", src, false) + require.NoError(t, err) + + runtime := createNewRuntime() + args := NewExecuteArgs() + args.Args["arg"] = "value" + + release := make(chan struct{}) + var releaseOnce sync.Once + releaseOrphan := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(releaseOrphan) + + abandonedSlotReleased := make(chan struct{}) + cleanupCalled := false + pathCleanupCalled := false + finalizeCalled := false + returnCalled := false + releaseCalled := false + + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + + session := newSession(sessionConfig{ + ctx: ctx, + runtime: runtime, + program: p, + args: args, + opts: &ExecuteOptions{ + Callback: func(rt *goja.Runtime) error { + return rt.Set("block", func() { + <-release + }) + }, + Cleanup: func(rt *goja.Runtime) { + cleanupCalled = true + }, + }, + prepareRuntime: func(rt *goja.Runtime) error { + return rt.Set("pathState", "temporary") + }, + cleanupRuntime: func(rt *goja.Runtime) { + pathCleanupCalled = true + }, + finalizeResult: func(rt *goja.Runtime, val goja.Value) (goja.Value, error) { + finalizeCalled = true + return val, nil + }, + returnRuntime: func(rt *goja.Runtime) { + returnCalled = true + }, + releaseSlot: func() { + releaseCalled = true + }, + releaseAbandonedSlot: func() { + close(abandonedSlotReleased) + }, + }) + + _, err = session.run() + require.ErrorIs(t, err, errRuntimeTerminationTimeout) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.False(t, cleanupCalled) + require.False(t, pathCleanupCalled) + require.False(t, finalizeCalled) + require.False(t, returnCalled) + require.False(t, releaseCalled) + + releaseOrphan() + select { + case <-abandonedSlotReleased: + case <-time.After(5 * time.Second): + t.Fatal("abandoned slot was not released after orphan exit") + } +} + +func TestPooledRuntimeAbandonmentSkipsCleanupAndRuntimeAccess(t *testing.T) { + src := `ExportAs("before", "value"); block(); 1` + p, err := SourceAutoMode(src, false) + require.NoError(t, err) + + args := NewExecuteArgs() + args.Args["arg"] = "value" + args.TemplateCtx["marker"] = "template" + + release := make(chan struct{}) + var releaseOnce sync.Once + releaseOrphan := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(releaseOrphan) + + report := make(chan []string, 1) + cleanupCalled := false + + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + + _, err = executeWithPoolingProgram(ctx, p, args, &ExecuteOptions{ + Source: &src, + Callback: func(rt *goja.Runtime) error { + return rt.Set("block", func() { + <-release + template := rt.Get("template").ToObject(rt) + exportType := "missing" + if _, ok := goja.AssertFunction(rt.Get(exportToken)); ok { + exportType = "function" + } + exportAsType := "missing" + if _, ok := goja.AssertFunction(rt.Get(exportAsToken)); ok { + exportAsType = "function" + } + report <- []string{ + exportType, + exportAsType, + template.Get("marker").String(), + rt.Get("arg").String(), + } + }) + }, + Cleanup: func(rt *goja.Runtime) { + cleanupCalled = true + }, + }) + require.ErrorIs(t, err, errRuntimeTerminationTimeout) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.False(t, cleanupCalled, "caller cleanup must not touch an abandoned runtime") + + releaseOrphan() + + select { + case got := <-report: + require.Equal(t, []string{"function", "function", "template", "value"}, got) + case <-time.After(5 * time.Second): + t.Fatal("orphaned runtime did not report state after release") + } +} + +func TestPooledAbandonedRuntimeIsNotReused(t *testing.T) { + src := `ExportAs("before", "value"); abandonedSentinel = "leaked"; block(); 1` + p, err := SourceAutoMode(src, false) + require.NoError(t, err) + + release := make(chan struct{}) + var releaseOnce sync.Once + releaseOrphan := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(releaseOrphan) + + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + + _, err = executeWithPoolingProgram(ctx, p, NewExecuteArgs(), &ExecuteOptions{ + Source: &src, + Callback: func(rt *goja.Runtime) error { + return rt.Set("block", func() { + <-release + }) + }, + }) + require.ErrorIs(t, err, errRuntimeTerminationTimeout) + + releaseOrphan() + + reuseSrc := `ExportAs("seen", typeof abandonedSentinel); true` + reuseProgram, err := SourceAutoMode(reuseSrc, false) + require.NoError(t, err) + + result, err := New().ExecuteWithOptions(t.Context(), reuseProgram, NewExecuteArgs(), &ExecuteOptions{ + Source: &reuseSrc, + TimeoutVariants: &types.Timeouts{ + JsCompilerExecutionTimeout: 5 * time.Second, + }, + }) + require.NoError(t, err) + require.Equal(t, "undefined", result["seen"]) +} + func executeScript(t *testing.T, executionID string, allowLocalFileAccess bool, script string) (ExecuteResult, error) { t.Helper() protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: allowLocalFileAccess}) @@ -330,6 +709,22 @@ func templateDirAlias(t *testing.T, templateDir string) (string, string) { return aliasPath, templateDir } +func useRuntimePool(t *testing.T, runtime *goja.Runtime) { + t.Helper() + + previousPool := gojapool + pool := &sync.Pool{ + New: func() interface{} { + return runtime + }, + } + pool.Put(runtime) + gojapool = pool + t.Cleanup(func() { + gojapool = previousPool + }) +} + type noopWriter struct { Callback func(data []byte, level levels.Level) } diff --git a/pkg/js/compiler/non-pool.go b/pkg/js/compiler/non-pool.go index f4419bc0f4..75218e056d 100644 --- a/pkg/js/compiler/non-pool.go +++ b/pkg/js/compiler/non-pool.go @@ -2,7 +2,6 @@ package compiler import ( "context" - "errors" "sync" "github.com/Mzack9999/goja" @@ -23,23 +22,16 @@ func executeWithoutPooling(ctx context.Context, p *goja.Program, args *ExecuteAr if err := ephemeraljsc.AddWithContext(ctx); err != nil { return nil, err } - // When the runtime is abandoned (an orphan goroutine outlived the - // interrupt grace period) ownership of the concurrency slot is - // transferred to a reaper inside executeWithRuntime which calls - // ephemeraljsc.Done after the orphan goroutine eventually exits; - // releasing the slot eagerly would let stuck callbacks bypass - // NonPoolingVMConcurrency. - slotOwnedByReaper := false - defer func() { - if !slotOwnedByReaper { - ephemeraljsc.Done() - } - }() runtime := createNewRuntime() - val, runErr := executeWithRuntime(ctx, runtime, p, args, opts, ephemeraljsc.Done) - if errors.Is(runErr, errRuntimeTerminationTimeout) { - slotOwnedByReaper = true - } - return val, runErr + session := newSession(sessionConfig{ + ctx: ctx, + runtime: runtime, + program: p, + args: args, + opts: opts, + releaseSlot: ephemeraljsc.Done, + releaseAbandonedSlot: ephemeraljsc.Done, + }) + return session.run() } diff --git a/pkg/js/compiler/pool.go b/pkg/js/compiler/pool.go index 35cf2c7ce9..6648a3711f 100644 --- a/pkg/js/compiler/pool.go +++ b/pkg/js/compiler/pool.go @@ -7,7 +7,6 @@ import ( "fmt" "reflect" "sync" - "time" "github.com/Mzack9999/goja" "github.com/Mzack9999/goja_nodejs/console" @@ -56,12 +55,7 @@ const ( exportAsToken = "ExportAs" ) -type gojaRunResult struct { - result goja.Value - err error -} - -// errRuntimeTerminationTimeout is returned by executeWithRuntime when the +// errRuntimeTerminationTimeout is returned by session when the // goroutine running goja.Runtime.RunProgram fails to terminate within the // grace period after an interrupt was raised. When this error is returned // the runtime MUST NOT be touched (no cleanup, no reuse, no Put back to @@ -97,119 +91,12 @@ var gojapool = &sync.Pool{ }, } -// executeWithRuntime runs program p on runtime. When the goroutine that -// actually executes the program fails to terminate within the grace period -// after a context-driven interrupt, the runtime is abandoned (see -// errRuntimeTerminationTimeout) and onOrphanExit is invoked from a reaper -// goroutine once the orphaned goroutine eventually returns. Callers that -// hold a concurrency slot or other resource on behalf of this runtime can -// use onOrphanExit to defer the release of that resource until the orphan -// is truly gone, instead of freeing it eagerly and letting fresh callers -// race the abandoned runtime. Pass nil if there is nothing to release. -func executeWithRuntime(ctx context.Context, runtime *goja.Runtime, p *goja.Program, args *ExecuteArgs, opts *ExecuteOptions, onOrphanExit func()) (goja.Value, error) { - if args == nil { - args = NewExecuteArgs() - } - if opts == nil { - opts = &ExecuteOptions{} - } - - runtimeAbandoned := false - defer func() { - if runtimeAbandoned { - return - } - cleanupRuntime(runtime, args, opts) - }() - - runtime.ClearInterrupt() - - // set template ctx - _ = runtime.Set("template", args.TemplateCtx) - // set args - for k, v := range args.Args { - _ = runtime.Set(k, v) - } - - runtime.SetContextValue("executionId", opts.ExecutionId) - runtime.SetContextValue("ctx", ctx) - enableRequire(runtime) - - // register extra callbacks if any - if opts.Callback != nil { - if err := opts.Callback(runtime); err != nil { - return nil, err - } - } - - resultChan := make(chan gojaRunResult, 1) - go func() { - defer func() { - if r := recover(); r != nil { - resultChan <- gojaRunResult{err: fmt.Errorf("panic: %s", r)} - } - }() - - result, err := runtime.RunProgram(p) - resultChan <- gojaRunResult{result, err} - }() - - var r gojaRunResult - select { - case <-ctx.Done(): - runtime.Interrupt(ctx.Err()) - select { - case r = <-resultChan: - // inner goroutine terminated cleanly after the interrupt - case <-time.After(time.Second): - // The goroutine running RunProgram is still alive even after - // being interrupted. We MUST NOT touch the runtime any more — - // doing so (cleanup, reuse, Put back to the pool) would race - // with the orphan goroutine still mutating goja's per-runtime - // state and trigger a fatal map race. Hand the runtime off to - // a reaper goroutine that waits for the orphan to actually - // finish before releasing any caller-owned resource (typically - // the pool concurrency slot). If the orphan never finishes - // (e.g. a native callback that blocks forever) the slot stays - // held — which is exactly the behaviour we want, because the - // stuck callback is still consuming runtime resources. - runtimeAbandoned = true - if onOrphanExit != nil { - go func() { - <-resultChan - onOrphanExit() - }() - } - return nil, fmt.Errorf("%w: %w", errRuntimeTerminationTimeout, ctx.Err()) - } - case r = <-resultChan: - // normal termination - } - - // At this point the inner goroutine has returned, so it is safe to - // touch the runtime again from this goroutine. - return r.result, r.err -} - -// cleanupRuntime resets the per-execution state of a goja runtime so that -// it can be safely reused by a subsequent caller. It MUST only be called -// once the goroutine that ran RunProgram on this runtime has returned; -// otherwise it races with that goroutine on goja's internal maps. -func cleanupRuntime(runtime *goja.Runtime, args *ExecuteArgs, opts *ExecuteOptions) { - _ = runtime.GlobalObject().Delete("template") // template ctx - for k := range args.Args { - _ = runtime.GlobalObject().Delete(k) - } - if opts != nil && opts.Cleanup != nil { - opts.Cleanup(runtime) - } - runtime.RemoveContextValue("executionId") - runtime.RemoveContextValue("ctx") -} - // ExecuteProgram executes a compiled program with the default options. // it deligates if a particular program should run in a pooled or non-pooled runtime func ExecuteProgram(ctx context.Context, p *goja.Program, args *ExecuteArgs, opts *ExecuteOptions) (goja.Value, error) { + if opts == nil { + opts = &ExecuteOptions{} + } if opts.Source == nil { // not-recommended anymore return executeWithoutPooling(ctx, p, args, opts) @@ -223,6 +110,9 @@ func ExecuteProgram(ctx context.Context, p *goja.Program, args *ExecuteArgs, opt // executes the actual js program func executeWithPoolingProgram(ctx context.Context, p *goja.Program, args *ExecuteArgs, opts *ExecuteOptions) (goja.Value, error) { + if opts == nil { + opts = &ExecuteOptions{} + } // its unknown (most likely cannot be done) to limit max js runtimes at a moment without making it static // unlike sync.Pool which reacts to GC and its purposes is to reuse objects rather than creating new ones lazySgInit() @@ -235,38 +125,45 @@ func executeWithPoolingProgram(ctx context.Context, p *goja.Program, args *Execu } runtime := gojapool.Get().(*goja.Runtime) - // runtimeAbandoned is set to true when executeWithRuntime returns - // errRuntimeTerminationTimeout, signalling that an orphan goroutine - // is still running on this runtime. In that case we drop the runtime - // instead of returning it to the pool (Go's GC will reclaim it once - // the orphan goroutine eventually exits) and we transfer ownership - // of the concurrency slot to a reaper goroutine inside - // executeWithRuntime, which releases it via pooljsc.Done once the - // orphan actually exits. Releasing the slot eagerly here would let a - // stream of stuck callbacks bypass PoolingJsVmConcurrency and trade - // the original map-race crash for unbounded resource growth. - runtimeAbandoned := false - defer func() { - if runtimeAbandoned { - return - } - gojapool.Put(runtime) - pooljsc.Done() - }() var buff bytes.Buffer opts.exports = make(map[string]interface{}) - defer func() { - if runtimeAbandoned { - // Don't touch the runtime: the orphan goroutine still owns it. - return - } - // remove below functions from runtime - _ = runtime.GlobalObject().Delete(exportAsToken) - _ = runtime.GlobalObject().Delete(exportToken) - }() + session := newSession(sessionConfig{ + ctx: ctx, + runtime: runtime, + program: p, + args: args, + opts: opts, + prepareRuntime: func(runtime *goja.Runtime) error { + registerExportHelpers(runtime, opts, &buff) + return nil + }, + cleanupRuntime: func(runtime *goja.Runtime) { + _ = runtime.GlobalObject().Delete(exportAsToken) + _ = runtime.GlobalObject().Delete(exportToken) + }, + finalizeResult: func(runtime *goja.Runtime, val goja.Value) (goja.Value, error) { + if val.Export() != nil { + // append last value to output + buff.WriteString(stringify(val, runtime)) + } + // and return it as result + return runtime.ToValue(buff.String()), nil + }, + returnRuntime: func(runtime *goja.Runtime) { + gojapool.Put(runtime) + }, + releaseSlot: pooljsc.Done, + releaseAbandonedSlot: pooljsc.Done, + onAbandon: func(err error) { + gologger.Warning().Msgf("js runtime did not terminate after interrupt; abandoning it to avoid concurrent use: %s", err) + }, + }) + return session.run() +} +func registerExportHelpers(runtime *goja.Runtime, opts *ExecuteOptions, buff *bytes.Buffer) { // register export functions _ = gojs.RegisterFuncWithSignature(runtime, gojs.FuncOpts{ Name: "Export", // we use string instead of const for documentation generation @@ -301,21 +198,6 @@ func executeWithPoolingProgram(ctx context.Context, p *goja.Program, args *Execu return goja.Null() }, }) - - val, err := executeWithRuntime(ctx, runtime, p, args, opts, pooljsc.Done) - if err != nil { - if errors.Is(err, errRuntimeTerminationTimeout) { - runtimeAbandoned = true - gologger.Warning().Msgf("js runtime did not terminate after interrupt; abandoning it to avoid concurrent use: %s", err) - } - return nil, err - } - if val.Export() != nil { - // append last value to output - buff.WriteString(stringify(val, runtime)) - } - // and return it as result - return runtime.ToValue(buff.String()), nil } // Internal purposes i.e generating bindings diff --git a/pkg/js/compiler/session.go b/pkg/js/compiler/session.go new file mode 100644 index 0000000000..01ba70aea1 --- /dev/null +++ b/pkg/js/compiler/session.go @@ -0,0 +1,232 @@ +package compiler + +import ( + "context" + "fmt" + "time" + + "github.com/Mzack9999/goja" +) + +// sessionState represents the various states a session can be in during its lifecycle. +type sessionState uint8 + +const ( + sessionRunning sessionState = iota + sessionCompleted + sessionFailed + sessionAbandoned +) + +type gojaRunResult struct { + result goja.Value + err error +} + +// sessionConfig defines the configuration for a session, including the runtime, +// program, arguments, and lifecycle hooks for preparation, cleanup, and result +// finalization. It also includes callbacks for handling resource release and +// abandonment scenarios. +type sessionConfig struct { + ctx context.Context + runtime *goja.Runtime + program *goja.Program + args *ExecuteArgs + opts *ExecuteOptions + + prepareRuntime func(*goja.Runtime) error + cleanupRuntime func(*goja.Runtime) + finalizeResult func(*goja.Runtime, goja.Value) (goja.Value, error) + + returnRuntime func(*goja.Runtime) + releaseSlot func() + releaseAbandonedSlot func() + onAbandon func(error) +} + +// session encapsulates the execution of a single JavaScript program, managing +// its lifecycle, including preparation, execution, and cleanup. +type session struct { + config sessionConfig + + resultChan chan gojaRunResult + state sessionState + + commonPrepared bool + commonCleaned bool + pathPrepared bool + pathCleaned bool +} + +func newSession(config sessionConfig) *session { + if config.args == nil { + config.args = NewExecuteArgs() + } + if config.opts == nil { + config.opts = &ExecuteOptions{} + } + return &session{ + config: config, + resultChan: make(chan gojaRunResult, 1), + } +} + +func executeWithRuntime(ctx context.Context, runtime *goja.Runtime, p *goja.Program, args *ExecuteArgs, opts *ExecuteOptions, onOrphanExit func()) (goja.Value, error) { + session := newSession(sessionConfig{ + ctx: ctx, + runtime: runtime, + program: p, + args: args, + opts: opts, + releaseAbandonedSlot: onOrphanExit, + }) + return session.run() +} + +func (s *session) run() (goja.Value, error) { + defer s.releaseAfterExit() + defer s.cleanupPath() + defer s.cleanupCommon() + + s.prepareCommon() + if s.config.prepareRuntime != nil { + s.pathPrepared = true + if err := s.config.prepareRuntime(s.config.runtime); err != nil { + s.state = sessionFailed + return nil, err + } + } + if s.config.opts.Callback != nil { + if err := s.config.opts.Callback(s.config.runtime); err != nil { + s.state = sessionFailed + return nil, err + } + } + + s.start() + result, err := s.wait() + if err != nil { + return nil, err + } + if result.err != nil { + s.state = sessionFailed + return nil, result.err + } + + s.cleanupCommon() + if s.config.finalizeResult != nil { + value, err := s.config.finalizeResult(s.config.runtime, result.result) + if err != nil { + s.state = sessionFailed + return nil, err + } + s.state = sessionCompleted + return value, nil + } + + s.state = sessionCompleted + return result.result, nil +} + +func (s *session) prepareCommon() { + s.commonPrepared = true + + s.config.runtime.ClearInterrupt() + _ = s.config.runtime.Set("template", s.config.args.TemplateCtx) + for k, v := range s.config.args.Args { + _ = s.config.runtime.Set(k, v) + } + + s.config.runtime.SetContextValue("executionId", s.config.opts.ExecutionId) + s.config.runtime.SetContextValue("ctx", s.config.ctx) + enableRequire(s.config.runtime) +} + +func (s *session) start() { + go func() { + defer func() { + if r := recover(); r != nil { + s.resultChan <- gojaRunResult{err: fmt.Errorf("panic: %s", r)} + } + }() + + result, err := s.config.runtime.RunProgram(s.config.program) + s.resultChan <- gojaRunResult{result: result, err: err} + }() +} + +func (s *session) wait() (gojaRunResult, error) { + select { + case <-s.config.ctx.Done(): + contextErr := s.config.ctx.Err() + s.config.runtime.Interrupt(contextErr) + + timer := time.NewTimer(time.Second) + defer timer.Stop() + + select { + case result := <-s.resultChan: + return result, nil + case <-timer.C: + return gojaRunResult{}, s.abandon(contextErr) + } + case result := <-s.resultChan: + return result, nil + } +} + +func (s *session) abandon(contextErr error) error { + s.state = sessionAbandoned + err := fmt.Errorf("%w: %w", errRuntimeTerminationTimeout, contextErr) + if s.config.releaseAbandonedSlot != nil { + resultChan := s.resultChan + releaseAbandonedSlot := s.config.releaseAbandonedSlot + go func() { + <-resultChan + releaseAbandonedSlot() + }() + } + if s.config.onAbandon != nil { + s.config.onAbandon(err) + } + return err +} + +func (s *session) cleanupCommon() { + if s.state == sessionAbandoned || !s.commonPrepared || s.commonCleaned { + return + } + s.commonCleaned = true + + _ = s.config.runtime.GlobalObject().Delete("template") + for k := range s.config.args.Args { + _ = s.config.runtime.GlobalObject().Delete(k) + } + if s.config.opts.Cleanup != nil { + s.config.opts.Cleanup(s.config.runtime) + } + s.config.runtime.RemoveContextValue("executionId") + s.config.runtime.RemoveContextValue("ctx") +} + +func (s *session) cleanupPath() { + if s.state == sessionAbandoned || !s.pathPrepared || s.pathCleaned { + return + } + s.pathCleaned = true + if s.config.cleanupRuntime != nil { + s.config.cleanupRuntime(s.config.runtime) + } +} + +func (s *session) releaseAfterExit() { + if s.state == sessionAbandoned { + return + } + if s.config.returnRuntime != nil { + s.config.returnRuntime(s.config.runtime) + } + if s.config.releaseSlot != nil { + s.config.releaseSlot() + } +}