Files
new-api-custom/pkg/jsplugin/engine.go
T

899 lines
28 KiB
Go

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()
}