Ctrl K
ComunidadGuías…

pkg/jsplugin/engine.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package jsplugin

import (
	"context"
	"encoding"
	"encoding/json"
	"errors"
	"fmt"
	"maps"
	"math/big"
	"reflect"
	"regexp"
	"slices"
	"strings"
	"sync"
	"time"
	"unsafe"

	"github.com/Calcium-Ion/moejs"
	"github.com/QuantumNous/new-api/common"
	"github.com/QuantumNous/new-api/logger"
)

const (
	DefaultCallTimeout = 5 * time.Second
	DefaultConcurrency = 8
)

var ErrCallAdmissionTimeout = errors.New("plugin call admission timed out")

const hookErrorMessageLimit = 512

// HookError reports a JavaScript exception thrown by a plugin hook. Message
// is the sanitized JS error message with engine prefixes stripped; it is safe
// to surface to API callers.
type HookError struct {
	Hook    string
	Message string
	wrapped error
}

func (e *HookError) Error() string {
	if e == nil || e.wrapped == nil {
		return "plugin hook failed"
	}
	return e.wrapped.Error()
}

func (e *HookError) Unwrap() error {
	if e == nil {
		return nil
	}
	return e.wrapped
}

// ResultError reports a hook result that CallInto could not store: one the
// host codec cannot encode (a NaN, an infinity, a cycle) or one the target's
// type rejects. Its message is the codec's.
type ResultError struct {
	err error
}

func (e *ResultError) Error() string {
	return e.err.Error()
}

func (e *ResultError) Unwrap() error {
	return e.err
}

func newHookError(hook, rawMessage string, wrapped error) *HookError {
	var b strings.Builder
	b.Grow(len(rawMessage))
	count := 0
	for _, r := range rawMessage {
		if count >= hookErrorMessageLimit {
			break
		}
		if r < 0x20 || (r >= 0x80 && r <= 0x9F) {
			r = ' '
		}
		b.WriteRune(r)
		count++
	}
	message := b.String()
	if message == "" {
		message = "plugin hook failed"
	}
	return &HookError{Hook: hook, Message: message, wrapped: wrapped}
}

var forbiddenSyntax = regexp.MustCompile(`(?m)(^|[^A-Za-z0-9_$])(async|await|import)([^A-Za-z0-9_$]|$)`)

type Options struct {
	Key         string
	Version     string
	Timeout     time.Duration
	Concurrency int
	Now         func() time.Time
	Log         func(string)
}

type Engine struct {
	key       string
	version   string
	timeout   time.Duration
	now       func() time.Time
	log       func(string)
	module    *moejs.Module
	pool      chan *runtimeInstance
	semaphore chan struct{}
	// parked bounds the runtimes that lent their slot to other calls while
	// utils.fetch waits on the network; its capacity is the concurrency.
	parked  chan struct{}
	hooksMu sync.RWMutex
	hooks   map[hookKey]moejs.Hook
	// exports holds the exports that were not undefined once the module
	// loaded; it is written only by Compile.
	exports map[string]struct{}
}

// hookKey names a cached hook handle. Contract hooks are an export and at
// most two members (protocols.<name>.<hook>); longer paths only come from
// fixture requests and are resolved on every call.
type hookKey struct {
	export  string
	members [2]string
	depth   int
}

// Fixture requests name arbitrary members, so the handle cache is bounded.
const maxCachedHooks = 256

type runtimeInstance struct {
	runtime    *moejs.Runtime
	logContext *runtimeLogContext
	// call is the hook call running on the runtime, nil between calls.
	call *runtimeCall
}

type runtimeLogContext struct {
	context context.Context
}

// Compile performs upload-time syntax checks and compiles an ESM plugin once.
// All engine-specific module and runtime handling is intentionally kept here.
func Compile(source string, options Options) (*Engine, error) {
	if match := forbiddenSyntax.FindString(sourceWithoutCommentsAndStrings(source)); match != "" {
		return nil, fmt.Errorf("unsupported plugin syntax %q: plugins must be synchronous and cannot import modules", strings.TrimSpace(match))
	}

	// The compiler never reads files, so a sourceMappingURL comment in
	// untrusted source stays inert.
	module, err := moejs.Compile(options.Key+".js", source)
	if err != nil {
		return nil, fmt.Errorf("compile plugin: %w", err)
	}
	// export ... from has no import keyword; the host links no modules.
	if requests := module.Requests(); len(requests) > 0 {
		return nil, fmt.Errorf("unsupported plugin syntax: re-export from %q: plugins must be synchronous and cannot import modules", requests[0])
	}

	timeout := options.Timeout
	if timeout <= 0 {
		timeout = DefaultCallTimeout
	}
	concurrency := options.Concurrency
	if concurrency <= 0 {
		concurrency = DefaultConcurrency
	}
	now := options.Now
	if now == nil {
		now = time.Now
	}

	engine := &Engine{
		key:       options.Key,
		version:   options.Version,
		timeout:   timeout,
		now:       now,
		log:       options.Log,
		module:    module,
		semaphore: make(chan struct{}, concurrency),
		parked:    make(chan struct{}, concurrency),
		pool:      make(chan *runtimeInstance, concurrency),
		hooks:     make(map[hookKey]moejs.Hook),
		exports:   make(map[string]struct{}),
	}
	instance, err := engine.newRuntime(context.Background())
	if err != nil {
		return nil, err
	}
	for _, name := range module.Exports() {
		if value, found := instance.runtime.Export(name); found && !value.IsUndefined() {
			engine.exports[name] = struct{}{}
		}
	}
	instance.logContext.context = nil
	engine.putRuntime(instance)
	return engine, nil
}

// Export returns one module export without exposing engine values outside the
// engine boundary. It is used for declarative exports such as meta.
func (e *Engine) Export(ctx context.Context, exportName string) (any, error) {
	select {
	case e.semaphore <- struct{}{}:
		defer func() { <-e.semaphore }()
	case <-ctx.Done():
		return nil, ctx.Err()
	}

	instance, err := e.getRuntime(ctx)
	if err != nil {
		return nil, err
	}
	reusable := true
	defer func() {
		instance.runtime.ClearInterrupt()
		instance.logContext.context = nil
		if reusable {
			e.putRuntime(instance)
		}
	}()
	timedOut := errors.New("plugin export timed out")
	watchdog := watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
	defer watchdog.stop()
	value, found := instance.runtime.Export(exportName)
	if !found || value.IsUndefined() {
		return nil, fmt.Errorf("plugin export %q not found", exportName)
	}
	result, err := instance.runtime.ToGo(value)
	if err == nil {
		return result, nil
	}
	reusable = false
	var interrupted *moejs.InterruptedError
	if errors.As(err, &interrupted) {
		return nil, fmt.Errorf("plugin %s@%s export %s interrupted: %w", e.key, e.version, exportName, interruptCause(interrupted))
	}
	var exception *moejs.Exception
	errors.As(err, &exception)
	return nil, fmt.Errorf("plugin %s@%s export %s failed: %w%s", e.key, e.version, exportName, err, thrownAt(instance.runtime, exception))
}

// HasExport reports whether a module export was defined when the plugin
// loaded: an export that was undefined then counts as absent, and a later
// assignment is not seen. Optional contract hooks should be detected with
// this method instead of relying on engine errors. It runs no JavaScript, so
// it neither waits for nor takes a call slot.
func (e *Engine) HasExport(exportName string) bool {
	_, found := e.exports[exportName]
	return found
}

// HasCallablePath reports whether an exported value, or a nested member below
// it, exists and is callable.
func (e *Engine) HasCallablePath(ctx context.Context, exportName string, members ...string) (bool, error) {
	select {
	case e.semaphore <- struct{}{}:
		defer func() { <-e.semaphore }()
	case <-ctx.Done():
		return false, ctx.Err()
	}
	hook, err := e.hook(exportName, members)
	if err != nil {
		return false, nil
	}
	instance, err := e.getRuntime(ctx)
	if err != nil {
		return false, err
	}
	reusable := true
	defer func() {
		instance.runtime.ClearInterrupt()
		instance.logContext.context = nil
		if reusable {
			e.putRuntime(instance)
		}
	}()
	timedOut := errors.New("plugin inspection timed out")
	watchdog := watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
	defer watchdog.stop()
	found, err := instance.runtime.Has(hook)
	if err == nil {
		return found, nil
	}
	reusable = false
	var interrupted *moejs.InterruptedError
	if errors.As(err, &interrupted) {
		return false, fmt.Errorf("plugin %s@%s hook %s inspection interrupted: %w", e.key, e.version, hook.Name(), interruptCause(interrupted))
	}
	var exception *moejs.Exception
	errors.As(err, &exception)
	return false, fmt.Errorf("plugin %s@%s hook %s inspection failed: %w%s", e.key, e.version, hook.Name(), err, thrownAt(instance.runtime, exception))
}

// RawJSON is a hook argument given as JSON text: the hook receives JSON.parse
// of it, members in order, whether it is an argument or inside one, with no Go
// values built in between; text nested too deep for JSON.parse is decoded with
// the host codec instead. The engine parses the bytes in place, without the
// copy ParseJSON makes, and strings of the call's result may share them, so
// the caller must never change them once passed.
type RawJSON []byte

// Call invokes one named module export and returns its JSON-compatible value.
func (e *Engine) Call(ctx context.Context, exportName string, args ...any) (result any, err error) {
	result, _, err = e.call(ctx, callOptions{}, exportName, nil, args...)
	return result, err
}

// CallInto invokes one named module export and stores its result in target as
// the host codec decodes the encoding of the value Call returns, without that
// round trip for plain results. A result the codec cannot encode or target's
// type rejects is a *ResultError, and target may then hold part of it.
func (e *Engine) CallInto(ctx context.Context, target any, exportName string, args ...any) error {
	_, _, err := e.call(ctx, callOptions{target: target}, exportName, nil, args...)
	return err
}

// CallJSONInto is CallInto with the semantics of JSON.stringify's text of the
// result instead of the host codec's encoding: objects keep their member
// order, which a json.RawMessage in target receives as text, members whose
// value is undefined are left out and toJSON runs.
func (e *Engine) CallJSONInto(ctx context.Context, target any, exportName string, args ...any) error {
	_, _, err := e.call(ctx, callOptions{target: target, stringify: true}, exportName, nil, args...)
	return err
}

// CallMember invokes a function stored on an exported object, such as a
// renderer in the renderers export.
func (e *Engine) CallMember(ctx context.Context, exportName, memberName string, args ...any) (result any, err error) {
	result, _, err = e.call(ctx, callOptions{}, exportName, []string{memberName}, args...)
	return result, err
}

// CallPath invokes a function nested below an exported object. It is used for
// protocol hooks such as protocols.openai_responses.renderEvents.
func (e *Engine) CallPath(ctx context.Context, exportName string, members []string, args ...any) (result any, err error) {
	result, _, err = e.call(ctx, callOptions{}, exportName, members, args...)
	return result, err
}

// CallPathInto is CallInto for a function nested below an exported object.
func (e *Engine) CallPathInto(ctx context.Context, target any, exportName string, members []string, args ...any) error {
	_, _, err := e.call(ctx, callOptions{target: target}, exportName, members, args...)
	return err
}

// CallPathWithAdmissionTimeout gives long-lived observers a separate bound for
// waiting on JavaScript capacity. Once admitted, the hook receives the
// engine's full execution timeout instead of inheriting time already spent in
// the semaphore queue.
func (e *Engine) CallPathWithAdmissionTimeout(
	ctx context.Context,
	admissionTimeout time.Duration,
	exportName string,
	members []string,
	args ...any,
) (result any, err error) {
	result, _, err = e.call(ctx, callOptions{admissionTimeout: admissionTimeout}, exportName, members, args...)
	return result, err
}

// CallPathWithMemberJSON is CallPathWithAdmissionTimeout that also returns the
// result's member as the text JSON.stringify writes for it, which keeps the
// member order of its objects. The text is nil when member is empty or the
// result has no such member.
func (e *Engine) CallPathWithMemberJSON(
	ctx context.Context,
	admissionTimeout time.Duration,
	member string,
	exportName string,
	members []string,
	args ...any,
) (result any, text json.RawMessage, err error) {
	return e.call(ctx, callOptions{admissionTimeout: admissionTimeout, jsonMember: member}, exportName, members, args...)
}

// callOptions is what a hook call does besides calling: the bound on waiting
// for a JavaScript slot, the Go value the result is stored in instead of
// being returned (from its JSON.stringify text when stringify is set), and a
// member of the result also returned as JSON text.
type callOptions struct {
	admissionTimeout time.Duration
	target           any
	stringify        bool
	jsonMember       string
}

func (e *Engine) call(ctx context.Context, opts callOptions, exportName string, members []string, args ...any) (any, json.RawMessage, error) {
	if err := e.acquireCallSlot(ctx, opts.admissionTimeout); err != nil {
		return nil, nil, err
	}
	call := &runtimeCall{engine: e, ctx: ctx, holdsSlot: true}
	// utils.fetch may lend the slot out; return what the call holds now.
	defer call.release()

	hook, err := e.hook(exportName, members)
	if err != nil {
		if len(members) == 0 {
			return nil, nil, fmt.Errorf("plugin export %q not found", exportName)
		}
		return nil, nil, fmt.Errorf("plugin hook %q not found", exportName)
	}
	instance, err := e.getRuntime(ctx)
	if err != nil {
		return nil, nil, err
	}
	call.runtime, call.hook = instance.runtime, hook.Name()
	call.fetcher, _ = ctx.Value(fetcherContextKey{}).(Fetcher)
	instance.call = call
	reusable := true
	defer func() {
		instance.runtime.ClearInterrupt()
		instance.logContext.context = nil
		instance.call = nil
		if reusable {
			// An idle pooled runtime must not keep this call's request
			// data alive.
			instance.runtime.ReleaseCallData()
			e.putRuntime(instance)
		}
	}()

	callArgs := make([]moejs.Value, 0, 4)
	for index, arg := range args {
		var converted moejs.Value
		if raw, ok := arg.(RawJSON); !ok {
			value, _ := pluginValue(instance.runtime, arg, 0)
			converted, err = instance.runtime.FromGo(value)
		} else if converted, err = instance.runtime.ParseJSONString(unsafe.String(unsafe.SliceData(raw), len(raw))); err != nil {
			// JSON.parse stops at a lower nesting depth than the host codec.
			var decoded any
			if err = common.Unmarshal(raw, &decoded); err == nil {
				converted, err = instance.runtime.FromGo(decoded)
			}
		}
		if err != nil {
			return nil, nil, fmt.Errorf("plugin %s@%s hook %s argument %d: %w", e.key, e.version, hook.Name(), index+1, err)
		}
		callArgs = append(callArgs, converted)
	}

	timedOut := errors.New("plugin call timed out")
	call.watchdog = watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
	// utils.fetch pauses and restarts the watchdog; stop whichever watch is
	// current when the call ends.
	defer call.watchdog.stop()

	value, err := instance.runtime.Call(hook, callArgs...)
	var interrupted *moejs.InterruptedError
	var exception *moejs.Exception
	if err == nil {
		var result any
		var memberText json.RawMessage
		switch {
		case opts.target != nil && opts.stringify:
			err = instance.runtime.Unmarshal(value, opts.target)
		case opts.target != nil:
			err = instance.runtime.ToGoInto(value, opts.target)
		default:
			result, err = instance.runtime.ToGo(value)
		}
		if err == nil && opts.jsonMember != "" {
			// After the result's Go value: AppendJSON may run a toJSON or
			// a getter, which ToGo never does.
			var member moejs.Value
			if member, err = instance.runtime.Get(value, opts.jsonMember); err == nil && !member.IsUndefined() {
				memberText, err = instance.runtime.AppendJSON(nil, member)
			}
		}
		if err == nil {
			return result, memberText, nil
		}
		if errors.As(err, &interrupted) {
			reusable = false
			return nil, nil, fmt.Errorf("plugin %s@%s hook %s interrupted: %w", e.key, e.version, hook.Name(), interruptCause(interrupted))
		}
		// Past ToGo's errors, ToGoInto fails as json.Marshal and
		// json.Unmarshal do, which is the host codec (common/json.go), and
		// Unmarshal and AppendJSON as JSON.stringify does (a BigInt, a cycle).
		var internal *moejs.InternalError
		if (opts.target != nil || opts.jsonMember != "") && !errors.As(err, &exception) && !errors.As(err, &internal) {
			return nil, nil, &ResultError{err: err}
		}
	}
	switch {
	case errors.Is(err, moejs.ErrHookNotFound):
		if len(members) == 0 {
			return nil, nil, fmt.Errorf("plugin export %q not found", exportName)
		}
		// Name the path up to the first missing member, as far as data
		// properties show it; getters on the path are not run again.
		hookName := exportName
		current, found := instance.runtime.Export(exportName)
		for _, member := range members {
			if !found || current.IsNullish() {
				break
			}
			hookName += "." + member
			if !current.IsObject() {
				break
			}
			current, found = current.AsObject().GetOwnDataValue(instance.runtime.Realm().KeyFromGoString(member))
		}
		return nil, nil, fmt.Errorf("plugin hook %q not found", hookName)
	case errors.Is(err, moejs.ErrNotCallable):
		return nil, nil, fmt.Errorf("plugin hook %q is not a function", hook.Name())
	case errors.As(err, &interrupted):
		reusable = false
		return nil, nil, fmt.Errorf("plugin %s@%s hook %s failed: %w", e.key, e.version, hook.Name(), interruptCause(interrupted))
	case errors.As(err, &exception):
		wrapped := fmt.Errorf("plugin %s@%s hook %s failed: %w%s", e.key, e.version, hook.Name(), err, thrownAt(instance.runtime, exception))
		return nil, nil, newHookError(hook.Name(), exception.Message(), wrapped)
	}
	reusable = false
	return nil, nil, fmt.Errorf("plugin %s@%s hook %s failed: %w", e.key, e.version, hook.Name(), err)
}

// hook resolves a hook path against the module's export table. Only
// declared exports resolve; the handle is cached, while the bindings it
// names are read live on every call.
func (e *Engine) hook(exportName string, members []string) (moejs.Hook, error) {
	key := hookKey{export: exportName, depth: len(members)}
	cacheable := len(members) <= len(key.members)
	if cacheable {
		copy(key.members[:], members)
		e.hooksMu.RLock()
		hook, found := e.hooks[key]
		e.hooksMu.RUnlock()
		if found {
			return hook, nil
		}
	}
	hook, err := e.module.Hook(exportName, members...)
	if err != nil || !cacheable {
		return hook, err
	}
	e.hooksMu.Lock()
	if len(e.hooks) < maxCachedHooks {
		e.hooks[key] = hook
	}
	e.hooksMu.Unlock()
	return hook, nil
}

func (e *Engine) acquireCallSlot(ctx context.Context, admissionTimeout time.Duration) error {
	if admissionTimeout <= 0 {
		select {
		case e.semaphore <- struct{}{}:
			return nil
		case <-ctx.Done():
			return ctx.Err()
		}
	}

	timer := time.NewTimer(admissionTimeout)
	defer timer.Stop()
	select {
	case e.semaphore <- struct{}{}:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	case <-timer.C:
		if err := ctx.Err(); err != nil {
			return err
		}
		return fmt.Errorf("%w: plugin %s@%s", ErrCallAdmissionTimeout, e.key, e.version)
	}
}

// interruptCause is what stopped a runtime: the context cause
// watchRuntimeContext interrupted it with, so errors.Is sees a caller's
// cancellation through the host error.
func interruptCause(interrupted *moejs.InterruptedError) error {
	if cause, ok := interrupted.Value.(error); ok {
		return cause
	}
	return interrupted
}

// thrownAt is the frame an Error was thrown from, as " at fn (file:line:col)",
// for host logs; it is empty for thrown values that are not Errors and for
// errors that are not exceptions (exception is nil).
func thrownAt(runtime *moejs.Runtime, exception *moejs.Exception) string {
	_, frames, found := strings.Cut(runtime.StackTrace(exception), "\n    at ")
	if !found {
		return ""
	}
	frame, _, _ := strings.Cut(frames, "\n")
	return " at " + frame
}

// maxPluginValueDepth bounds the argument walk; deeper values, which no
// host-built argument reaches, are passed on as they are.
const maxPluginValueDepth = 512

// pluginValueContainers are the container types moejs converts; named types
// with one of these underlying types are only retyped.
var pluginValueContainers = []reflect.Type{
	reflect.TypeFor[map[string]any](),
	reflect.TypeFor[map[string]string](),
	reflect.TypeFor[map[string][]string](),
	reflect.TypeFor[[]any](),
	reflect.TypeFor[[]string](),
	reflect.TypeFor[[]map[string]any](),
}

// pluginValue returns a hook argument in the shapes moejs converts: nil,
// booleans, numbers, strings, JSON-shaped maps and slices and engine values.
// A RawJSON inside the argument becomes rt's parse of it (RawJSON); other Go
// values (structs, pointers, maps and slices of other element types) take
// their JSON form through the configured codec, and named scalars and
// containers become their underlying type. changed is false when v has
// supported shapes throughout, the usual case, so an argument is passed on
// without a copy.
func pluginValue(rt *moejs.Runtime, v any, depth int) (converted any, changed bool) {
	if depth > maxPluginValueDepth {
		return v, false
	}
	switch typed := v.(type) {
	case nil, bool, string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64,
		float32, float64, json.Number, *big.Int, []byte, []string, map[string]string, map[string][]string,
		moejs.Value, moejs.NativeFunc:
		return v, false
	case RawJSON:
		if parsed, err := rt.ParseJSONString(unsafe.String(unsafe.SliceData(typed), len(typed))); err == nil {
			return parsed, true
		}
		// JSON.parse stops at a lower nesting depth than the host codec.
		var decoded any
		if err := common.Unmarshal(typed, &decoded); err != nil {
			return v, false
		}
		return decoded, true
	case map[string]any:
		var copied map[string]any
		for key, item := range typed {
			if item, changed = pluginValue(rt, item, depth+1); changed {
				if copied == nil {
					copied = maps.Clone(typed)
				}
				copied[key] = item
			}
		}
		if copied == nil {
			return v, false
		}
		return copied, true
	case []any:
		var copied []any
		for index, item := range typed {
			if item, changed = pluginValue(rt, item, depth+1); changed {
				if copied == nil {
					copied = slices.Clone(typed)
				}
				copied[index] = item
			}
		}
		if copied == nil {
			return v, false
		}
		return copied, true
	case []map[string]any:
		var copied []map[string]any
		for index, item := range typed {
			if next, changed := pluginValue(rt, item, depth+1); changed {
				if copied == nil {
					copied = slices.Clone(typed)
				}
				copied[index] = next.(map[string]any)
			}
		}
		if copied == nil {
			return v, false
		}
		return copied, true
	}
	_, marshaler := v.(json.Marshaler)
	_, textMarshaler := v.(encoding.TextMarshaler)
	if !marshaler && !textMarshaler {
		value := reflect.ValueOf(v)
		switch value.Kind() {
		case reflect.Bool:
			return value.Bool(), true
		case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
			return value.Int(), true
		case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
			return value.Uint(), true
		case reflect.Float32, reflect.Float64:
			return value.Float(), true
		case reflect.String:
			return value.String(), true
		case reflect.Map, reflect.Slice:
			for _, container := range pluginValueContainers {
				if value.Type().ConvertibleTo(container) {
					converted, _ = pluginValue(rt, value.Convert(container).Interface(), depth)
					return converted, true
				}
			}
		}
	}
	data, err := common.Marshal(v)
	if err != nil {
		return v, false
	}
	var decoded any
	if err = common.Unmarshal(data, &decoded); err != nil {
		return v, false
	}
	return decoded, true
}

// Idle runtimes are bounded by the execution limit and survive GC. Start with
// one instance and grow only when concurrent work actually needs more.
func (e *Engine) putRuntime(instance *runtimeInstance) {
	select {
	case e.pool <- instance:
	default:
	}
}

func (e *Engine) getRuntime(ctx context.Context) (*runtimeInstance, error) {
	select {
	case instance := <-e.pool:
		instance.logContext.context = ctx
		return instance, nil
	default:
		return e.newRuntime(ctx)
	}
}

// runtimeWatchdog interrupts a runtime with timeoutError once it has run for
// its timeout, or with the context's cause once ctx ends. utils.fetch pauses
// it, so the timeout counts JavaScript time only. A timeout callback must
// finish before its runtime can be reused: merely stopping a timer does not
// wait for an already-started Interrupt call.
type runtimeWatchdog struct {
	runtime      *moejs.Runtime
	ctx          context.Context
	timeoutError error
	remaining    time.Duration
	started      time.Time
	watch        context.Context
	stopWatch    func() bool
	cancel       context.CancelFunc
	interrupted  chan struct{}
	paused       bool
}

func watchRuntimeContext(runtime *moejs.Runtime, ctx context.Context, timeout time.Duration, timeoutError error) *runtimeWatchdog {
	watchdog := &runtimeWatchdog{runtime: runtime, ctx: ctx, timeoutError: timeoutError, remaining: timeout}
	watchdog.start()
	return watchdog
}

func (w *runtimeWatchdog) start() {
	watch, cancel := context.WithTimeoutCause(w.ctx, w.remaining, w.timeoutError)
	interrupted := make(chan struct{})
	w.stopWatch = context.AfterFunc(watch, func() {
		w.runtime.Interrupt(context.Cause(watch))
		close(interrupted)
	})
	w.watch, w.cancel, w.interrupted = watch, cancel, interrupted
	w.started, w.paused = time.Now(), false
}

func (w *runtimeWatchdog) stop() {
	if w.paused {
		return
	}
	if !w.stopWatch() {
		<-w.interrupted
	}
	w.cancel()
}

// pause stops the clock while a host function waits. It reports false when
// the watchdog already fired: its interrupt is then ending the call, and the
// host function must return instead of waiting.
func (w *runtimeWatchdog) pause() bool {
	if !w.stopWatch() {
		<-w.interrupted
		return false
	}
	w.cancel()
	w.remaining -= time.Since(w.started)
	w.paused = true
	return true
}

// resume restarts the clock with the time left at pause and the same timeout
// cause. When the time is up or ctx ended meanwhile, it waits for the
// interrupt that follows, so the host function can return it.
func (w *runtimeWatchdog) resume() {
	w.start()
	if w.watch.Err() != nil {
		<-w.interrupted
	}
}

func (e *Engine) newRuntime(ctx context.Context) (*runtimeInstance, error) {
	// Code compiled from strings would bypass the upload-time syntax checks,
	// so eval and the Function constructors throw an EvalError.
	runtime := moejs.NewRuntime(moejs.Options{DisableDynamicCode: true})
	logContext := &runtimeLogContext{context: ctx}
	instance := &runtimeInstance{runtime: runtime, logContext: logContext}
	logOutput := e.log
	if logOutput == nil {
		logOutput = func(message string) {
			logger.LogDebug(logContext.context, "task_plugin subsystem=runtime event=console message=%q", message)
		}
	}
	if err := injectGlobals(runtime, func() string {
		return fmt.Sprintf("[plugin:%s@%s]", e.key, e.version)
	}, e.now, logOutput, func() *runtimeCall { return instance.call }); err != nil {
		return nil, fmt.Errorf("inject plugin utils: %w", err)
	}
	timedOut := errors.New("plugin initialization timed out")
	watchdog := watchRuntimeContext(runtime, ctx, e.timeout, timedOut)
	err := runtime.Load(e.module)
	watchdog.stop()
	runtime.ClearInterrupt()
	var interrupted *moejs.InterruptedError
	if errors.As(err, &interrupted) {
		return nil, fmt.Errorf("initialize plugin %s@%s: %w", e.key, e.version, interruptCause(interrupted))
	}
	if err != nil {
		return nil, fmt.Errorf("evaluate plugin: %w", err)
	}
	return instance, nil
}

func sourceWithoutCommentsAndStrings(source string) string {
	var output strings.Builder
	output.Grow(len(source))
	quote := byte(0)
	escaped := false
	lineComment := false
	blockComment := false
	for i := 0; i < len(source); i++ {
		current := source[i]
		next := byte(0)
		if i+1 < len(source) {
			next = source[i+1]
		}
		if lineComment {
			if current == '\n' {
				lineComment = false
				output.WriteByte('\n')
			} else {
				output.WriteByte(' ')
			}
			continue
		}
		if blockComment {
			if current == '*' && next == '/' {
				blockComment = false
				output.WriteString("  ")
				i++
			} else {
				output.WriteByte(' ')
			}
			continue
		}
		if quote != 0 {
			output.WriteByte(' ')
			if escaped {
				escaped = false
			} else if current == '\\' {
				escaped = true
			} else if current == quote {
				quote = 0
			}
			continue
		}
		if current == '/' && next == '/' {
			lineComment = true
			output.WriteString("  ")
			i++
			continue
		}
		if current == '/' && next == '*' {
			blockComment = true
			output.WriteString("  ")
			i++
			continue
		}
		if current == '\'' || current == '"' || current == '`' {
			quote = current
			output.WriteByte(' ')
			continue
		}
		output.WriteByte(current)
	}
	return output.String()
}