mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 02:14:39 +00:00
280 lines
9.1 KiB
Go
280 lines
9.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dop251/goja"
|
|
"github.com/google/uuid"
|
|
lru "github.com/hashicorp/golang-lru/v2"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
|
)
|
|
|
|
// MaxScriptSourceBytes is the mandatory safety-floor cap on script source size
|
|
// (brainstorm §Critical safety items #2). Validated again at execute time as a
|
|
// belt-and-suspenders check after Phase 03 wires the Create/Update validation.
|
|
const MaxScriptSourceBytes = 32 * 1024
|
|
|
|
// MaxStdoutBytes is the mandatory safety-floor cap on captured console output
|
|
// per execution. Writes past the cap are dropped with a `... truncated` marker.
|
|
const MaxStdoutBytes = 4 * 1024
|
|
|
|
// defaultGlobalConcurrency / defaultPerTenantConcurrency / defaultCacheSize
|
|
// match the brainstorm safety-floor defaults. Overridden by NewScriptHandler
|
|
// arguments when the gateway config specifies different values.
|
|
const (
|
|
defaultGlobalConcurrency = 10
|
|
defaultPerTenantConcurrency = 3
|
|
defaultCacheSize = 500
|
|
)
|
|
|
|
// ScriptHandler implements hooks.Handler via Goja ES5.1.
|
|
//
|
|
// Safety floor — all mandatory, independent of any future budget system:
|
|
// - two-layer semaphore (global cap + per-tenant cap) prevents one runaway
|
|
// tenant from starving the global pool.
|
|
// - LRU program cache (bounded) so repeated invocations skip recompile without
|
|
// unbounded memory growth.
|
|
// - source size cap + stdout size cap on every execution.
|
|
//
|
|
// Concurrency: a ScriptHandler is safe to share across goroutines. Each
|
|
// Execute call allocates a fresh goja.Runtime — runtimes are never shared.
|
|
type ScriptHandler struct {
|
|
// globalSem is a buffered channel acting as the global concurrency ceiling.
|
|
globalSem chan struct{}
|
|
// perTenantCap bounds the per-tenant sub-semaphore size.
|
|
perTenantCap int
|
|
// tenantSems: lazily allocated per-tenant slot pool; protects runaway tenant
|
|
// from saturating all global slots.
|
|
tenantMu sync.Mutex
|
|
tenantSems map[uuid.UUID]*tenantSlot
|
|
|
|
// progCache: LRU bounded at cacheSize entries, keyed by (hookID, version).
|
|
// Invalidated on hook update/delete through InvalidateHook.
|
|
progCache *lru.Cache[progCacheKey, *goja.Program]
|
|
}
|
|
|
|
// tenantSlot tracks a single tenant's concurrency allowance plus last-use time
|
|
// so a background cleanup can reclaim idle tenants (future Phase; scaffolding
|
|
// here is intentional).
|
|
type tenantSlot struct {
|
|
sem chan struct{}
|
|
lastUsed time.Time
|
|
}
|
|
|
|
type progCacheKey struct {
|
|
ID uuid.UUID
|
|
Version int
|
|
}
|
|
|
|
// NewScriptHandler constructs a ScriptHandler with the given caps. Any cap <= 0
|
|
// falls back to the package default. The LRU allocation only fails on
|
|
// size <= 0, which we already guard, so the error branch is unreachable.
|
|
func NewScriptHandler(globalConcurrency, perTenantConcurrency, cacheSize int) *ScriptHandler {
|
|
if globalConcurrency <= 0 {
|
|
globalConcurrency = defaultGlobalConcurrency
|
|
}
|
|
if perTenantConcurrency <= 0 {
|
|
perTenantConcurrency = defaultPerTenantConcurrency
|
|
}
|
|
if cacheSize <= 0 {
|
|
cacheSize = defaultCacheSize
|
|
}
|
|
cache, _ := lru.New[progCacheKey, *goja.Program](cacheSize)
|
|
return &ScriptHandler{
|
|
globalSem: make(chan struct{}, globalConcurrency),
|
|
perTenantCap: perTenantConcurrency,
|
|
tenantSems: make(map[uuid.UUID]*tenantSlot),
|
|
progCache: cache,
|
|
}
|
|
}
|
|
|
|
// InvalidateHook removes cached program entries related to the given hook.
|
|
// Phase 03 wires this from hooks.update / hooks.delete store events. Wave 1
|
|
// uses a full purge for simplicity — LRU v2 does not expose keyed predicates,
|
|
// and refill is cheap (single recompile per subsequent invocation).
|
|
func (h *ScriptHandler) InvalidateHook(_ uuid.UUID) {
|
|
h.progCache.Purge()
|
|
}
|
|
|
|
// Execute runs the script hook: acquire semaphores → compile/cache →
|
|
// fresh goja runtime → harden → bind frozen event → run → parse return.
|
|
// Panics, goja interrupts, and return-parse failures all map to DecisionError;
|
|
// ctx cancellation maps to DecisionTimeout.
|
|
func (h *ScriptHandler) Execute(ctx context.Context, cfg hooks.HookConfig, ev hooks.Event) (hooks.Decision, error) {
|
|
select {
|
|
case h.globalSem <- struct{}{}:
|
|
case <-ctx.Done():
|
|
return hooks.DecisionTimeout, ctx.Err()
|
|
}
|
|
defer func() { <-h.globalSem }()
|
|
|
|
tsem := h.acquireTenantSem(ev.TenantID)
|
|
select {
|
|
case tsem <- struct{}{}:
|
|
case <-ctx.Done():
|
|
return hooks.DecisionTimeout, ctx.Err()
|
|
}
|
|
defer func() { <-tsem }()
|
|
|
|
source, ok := cfg.Config["source"].(string)
|
|
if !ok || source == "" {
|
|
return hooks.DecisionError, errors.New("script: missing 'source' in config")
|
|
}
|
|
if len(source) > MaxScriptSourceBytes {
|
|
return hooks.DecisionError, fmt.Errorf("script: source exceeds %d bytes", MaxScriptSourceBytes)
|
|
}
|
|
|
|
prog, err := h.compile(cfg, source)
|
|
if err != nil {
|
|
return hooks.DecisionError, sanitizeError(err)
|
|
}
|
|
|
|
rt := goja.New()
|
|
if err := applyHardening(rt); err != nil {
|
|
return hooks.DecisionError, sanitizeError(err)
|
|
}
|
|
var stdout strings.Builder
|
|
captureStdout(rt, &stdout)
|
|
if err := bindEvent(rt, ev); err != nil {
|
|
return hooks.DecisionError, sanitizeError(err)
|
|
}
|
|
|
|
// Watchdog: interrupt the runtime on ctx cancellation. done closes on normal
|
|
// exit so the goroutine always returns.
|
|
done := make(chan struct{})
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
rt.Interrupt("context cancelled")
|
|
case <-done:
|
|
}
|
|
}()
|
|
|
|
var (
|
|
result goja.Value
|
|
execErr error
|
|
)
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
execErr = fmt.Errorf("script panic: %v", r)
|
|
}
|
|
}()
|
|
if _, perr := rt.RunProgram(prog); perr != nil {
|
|
execErr = perr
|
|
return
|
|
}
|
|
handleFn, ok := goja.AssertFunction(rt.Get("handle"))
|
|
if !ok {
|
|
execErr = errors.New("script: export function handle(event)")
|
|
return
|
|
}
|
|
result, execErr = handleFn(goja.Undefined(), rt.Get("event"))
|
|
}()
|
|
close(done)
|
|
|
|
if execErr != nil {
|
|
if ctx.Err() != nil {
|
|
return hooks.DecisionTimeout, ctx.Err()
|
|
}
|
|
return hooks.DecisionError, sanitizeError(execErr)
|
|
}
|
|
|
|
dec, reason, additionalContext, updated, parseErr := parseReturn(rt, result)
|
|
if parseErr != nil {
|
|
return hooks.DecisionError, parseErr
|
|
}
|
|
|
|
// Wave 1 reserves ask/defer but does not implement them. Treat as block +
|
|
// warn so operators can see that a hook wants human/external arbitration.
|
|
if dec == hooks.DecisionAsk || dec == hooks.DecisionDefer {
|
|
slog.Warn("hooks.decision_not_yet_implemented",
|
|
"hook_id", cfg.ID, "decision", string(dec))
|
|
dec = hooks.DecisionBlock
|
|
}
|
|
|
|
// Write non-decision outputs for dispatcher pickup (Phase 03 applies them
|
|
// only when cfg.Source == "builtin"). Standalone tests may not provision a
|
|
// ScriptResult — ScriptResultFrom returns nil in that case, which is fine.
|
|
if out := stdout.String(); out != "" {
|
|
slog.Debug("hooks.script.console_output", "hook_id", cfg.ID, "output", out)
|
|
}
|
|
if r := hooks.ScriptResultFrom(ctx); r != nil {
|
|
r.Reason = reason
|
|
r.AdditionalContext = additionalContext
|
|
r.UpdatedInput = updated
|
|
r.Stdout = stdout.String()
|
|
}
|
|
return dec, nil
|
|
}
|
|
|
|
// tenantSlotIdleTimeout is how long a per-tenant semaphore can sit unused
|
|
// before it becomes eligible for sweep. Picked at 1h: long enough to cover
|
|
// idle gaps between user sessions; short enough that long-running multi-tenant
|
|
// gateways with churning tenants don't accumulate dead entries forever.
|
|
const tenantSlotIdleTimeout = 1 * time.Hour
|
|
|
|
// tenantSemSweepThreshold defers sweep cost until the map is large enough to
|
|
// matter. Below this, the O(N) scan per acquire is wasted work.
|
|
const tenantSemSweepThreshold = 64
|
|
|
|
// acquireTenantSem returns the per-tenant slot channel, lazily allocating on
|
|
// first use. Zero-UUID tenants (system calls) share the same slot keyed by
|
|
// uuid.Nil, which is acceptable because system-source hooks are still bounded
|
|
// by the global cap.
|
|
//
|
|
// Sweep: when the map crosses tenantSemSweepThreshold entries, idle slots
|
|
// (no in-flight grants AND last-used > tenantSlotIdleTimeout ago) are dropped
|
|
// opportunistically. Goroutine-free design — sweep happens under the same
|
|
// lock the acquire path already takes, so steady-state cost stays one
|
|
// CompareAndSwap-equivalent for the common path.
|
|
func (h *ScriptHandler) acquireTenantSem(tid uuid.UUID) chan struct{} {
|
|
h.tenantMu.Lock()
|
|
defer h.tenantMu.Unlock()
|
|
if len(h.tenantSems) >= tenantSemSweepThreshold {
|
|
now := time.Now()
|
|
for k, v := range h.tenantSems {
|
|
if k == tid {
|
|
continue
|
|
}
|
|
if len(v.sem) == 0 && now.Sub(v.lastUsed) > tenantSlotIdleTimeout {
|
|
delete(h.tenantSems, k)
|
|
}
|
|
}
|
|
}
|
|
s, ok := h.tenantSems[tid]
|
|
if !ok {
|
|
s = &tenantSlot{sem: make(chan struct{}, h.perTenantCap)}
|
|
h.tenantSems[tid] = s
|
|
}
|
|
s.lastUsed = time.Now()
|
|
return s.sem
|
|
}
|
|
|
|
// sanitizeError trims stack-frame text from goja errors. Two strip passes:
|
|
// first newline (multi-line stacks) and first ` at ` (single-line inline
|
|
// frames like `Error: boom at handle (<id>:1:32(3))`). Goja embeds source
|
|
// file IDs + line/col numbers in both forms; we don't want that leaking to
|
|
// tenant admins through dispatcher audit. One-line messages without frame
|
|
// info stay intact.
|
|
func sanitizeError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
msg := err.Error()
|
|
if idx := strings.Index(msg, "\n"); idx > 0 {
|
|
msg = msg[:idx]
|
|
}
|
|
if idx := strings.Index(msg, " at "); idx > 0 {
|
|
msg = strings.TrimRight(msg[:idx], " ")
|
|
}
|
|
return errors.New(msg)
|
|
}
|