Files
2025-12-22 18:41:13 +08:00

360 lines
8.7 KiB
Go

package local_runtime
import (
"bufio"
"errors"
"fmt"
"io"
"os/exec"
"sync"
"time"
"github.com/google/uuid"
"github.com/langgenius/dify-plugin-daemon/internal/types/app"
"github.com/langgenius/dify-plugin-daemon/pkg/entities/plugin_entities"
)
const (
MAX_ERR_MSG_LEN = 1024
MAX_HEARTBEAT_INTERVAL = 120 * time.Second
)
type PluginInstance struct {
instanceId string
pluginUniqueIdentifier string
cmd *exec.Cmd
inWriter io.WriteCloser
outReader io.ReadCloser
errReader io.ReadCloser
l *sync.Mutex
listener map[string]func([]byte)
started bool // mark the instance as started, it will be set to true when the first heartbeat is received
shutdown bool // mark the instance as shutdown, it will be set to true when stdout reader is closed
// app config
appConfig *app.Config
// error message container
errMessage string
lastErrMessageUpdatedAt time.Time
// the last time the plugin sent a heartbeat
lastActiveAt time.Time
// notifier
notifiers []PluginInstanceNotifier
notifierLock *sync.Mutex
}
type PluginInstanceConfig struct {
StdoutBufferSize int
StdoutMaxBufferSize int
}
func newPluginInstance(
pluginUniqueIdentifier string,
e *exec.Cmd,
writer io.WriteCloser,
reader io.ReadCloser,
errReader io.ReadCloser,
appConfig *app.Config,
) *PluginInstance {
instanceId, _ := uuid.NewV7()
instance := &PluginInstance{
instanceId: instanceId.String(),
pluginUniqueIdentifier: pluginUniqueIdentifier,
cmd: e,
inWriter: writer,
outReader: reader,
errReader: errReader,
l: &sync.Mutex{},
appConfig: appConfig,
listener: make(map[string]func([]byte)),
notifiers: []PluginInstanceNotifier{},
notifierLock: &sync.Mutex{},
}
return instance
}
func (s *PluginInstance) setupStdioEventListener(session_id string, listener func([]byte)) {
s.l.Lock()
defer s.l.Unlock()
s.listener[session_id] = listener
}
func (s *PluginInstance) removeStdioHandlerListener(session_id string) {
s.l.Lock()
defer s.l.Unlock()
delete(s.listener, session_id)
}
func (s *PluginInstance) Error() error {
if time.Since(s.lastErrMessageUpdatedAt) < 60*time.Second {
if s.errMessage != "" {
return errors.New(s.errMessage)
}
}
return nil
}
// Stop stops the stdio, of course, it will shutdown the plugin asynchronously
func (s *PluginInstance) Stop() {
s.inWriter.Close()
s.outReader.Close()
s.errReader.Close()
}
// StartStdout starts to read the stdout of the plugin
// and parse the stdout data to trigger corresponding listeners
//
// # Whenever the stdout reader is closed, subprocess will be killed and reaped
//
// At a high-level view of the architecture design
// Stdout actually take responsibility of the existing of the plugin instance
//
// CLOSE STDOUT = KILL and REAP subprocess
// CLOSE INSTANCE = CLOSE STDOUT
//
// In the above scope, the subprocess was killed by daemon,
// Once the subprocess exists itself, STDOUT always close, which results in `CLOSE STDOUT`
func (s *PluginInstance) StartStdout() {
defer func() {
// notify shutdown signal
s.WalkNotifiers(func(notifier PluginInstanceNotifier) {
notifier.OnInstanceShutdown(s)
})
}()
once := &sync.Once{}
scanner := bufio.NewScanner(s.outReader)
scanner.Buffer(
make([]byte, s.appConfig.GetLocalRuntimeBufferSize()),
s.appConfig.GetLocalRuntimeMaxBufferSize(),
)
for scanner.Scan() {
// read data, once s.outReader.Close was called
// scanner.Scan() breaks immediately
data := scanner.Bytes()
if len(data) == 0 {
continue
}
// handle stdout
s.handleStdout(data, once)
// notify stdout notifiers
s.WalkNotifiers(func(notifier PluginInstanceNotifier) {
notifier.OnInstanceStdout(s, data)
})
}
if err := scanner.Err(); err != nil {
s.WalkNotifiers(func(notifier PluginInstanceNotifier) {
notifier.OnInstanceErrorLog(
s,
fmt.Errorf(
"plugin %s has an error on stdout: %s",
s.pluginUniqueIdentifier,
err,
),
)
})
}
// once reader of stdout is closed, kill subprocess
if err := s.cmd.Process.Kill(); err != nil {
// no need to return here, just log the error, it's perhaps the process was exited already
// and the kill command fails
s.WalkNotifiers(func(notifier PluginInstanceNotifier) {
notifier.OnInstanceErrorLog(s, fmt.Errorf("failed to kill subprocess: %s", err.Error()))
})
}
// collect subprocess, avoid zombie processes
if _, err := s.cmd.Process.Wait(); err != nil {
s.WalkNotifiers(func(notifier PluginInstanceNotifier) {
notifier.OnInstanceErrorLog(s, fmt.Errorf("failed to reap subprocess: %s", err.Error()))
})
}
}
// handles stdout data and notify corresponding listeners
func (s *PluginInstance) handleStdout(data []byte, once *sync.Once) {
plugin_entities.ParsePluginUniversalEvent(
data,
"",
func(sessionId string, data []byte) {
// FIX: avoid deadlock to plugin invoke
s.l.Lock()
listener := s.listener[sessionId]
s.l.Unlock()
if listener != nil {
listener(data)
}
},
func() {
// heartbeat
s.WalkNotifiers(func(notifier PluginInstanceNotifier) {
notifier.OnInstanceHeartbeat(s)
})
// only first heartbeat will trigger this
once.Do(func() {
s.WalkNotifiers(func(notifier PluginInstanceNotifier) {
notifier.OnInstanceReady(s)
})
})
},
func(err string) {
// error log
s.WalkNotifiers(func(notifier PluginInstanceNotifier) {
notifier.OnInstanceErrorLog(s, errors.New(err))
})
},
func(logEvent plugin_entities.PluginLogEvent) {
// plain text log
s.WalkNotifiers(func(notifier PluginInstanceNotifier) {
notifier.OnInstanceLog(s, logEvent)
})
},
)
}
// WriteError writes the error message to the stdio holder
// it will keep the last 1024 bytes of the error message
func (s *PluginInstance) WriteError(msg string) {
if len(msg) > MAX_ERR_MSG_LEN {
msg = msg[:MAX_ERR_MSG_LEN]
}
reduce := len(msg) + len(s.errMessage) - MAX_ERR_MSG_LEN
if reduce > 0 {
if reduce > len(s.errMessage) {
s.errMessage = ""
} else {
s.errMessage = s.errMessage[reduce:]
}
}
s.errMessage += msg
s.lastErrMessageUpdatedAt = time.Now()
}
// StartStderr starts to read the stderr of the plugin
// it will write the error message to the stdio holder
func (s *PluginInstance) StartStderr() {
for {
buf := make([]byte, 1024)
n, err := s.errReader.Read(buf)
if err != nil && err != io.EOF {
break
} else if err != nil {
s.WriteError(fmt.Sprintf("%s\n", buf[:n]))
break
}
if n > 0 {
s.WriteError(fmt.Sprintf("%s\n", buf[:n]))
}
}
}
// Monitor monitors the plugin instance
// it will return an error if the plugin is not active
// you can also call `Stop()` to stop the monitoring process
func (s *PluginInstance) Monitor() error {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
// check status of plugin every 5 seconds
for range ticker.C {
if s.shutdown {
// process was closed already, exit monitoring
return nil
}
// check heartbeat
if time.Since(s.lastActiveAt) > MAX_HEARTBEAT_INTERVAL {
s.WalkNotifiers(func(notifier PluginInstanceNotifier) {
// notify handlers
notifier.OnInstanceLaunchFailed(
s,
fmt.Errorf(
"plugin %s is not active for %f seconds, it may be dead, captured error logs: %v",
s.pluginUniqueIdentifier,
time.Since(s.lastActiveAt).Seconds(),
s.Error(),
),
)
})
// dead instance detected, kill it
s.Stop()
return ErrRuntimeNotActive
}
if time.Since(s.lastActiveAt) > MAX_HEARTBEAT_INTERVAL/2 {
// notify handlers
s.WalkNotifiers(func(notifier PluginInstanceNotifier) {
notifier.OnInstanceWarningLog(
s,
fmt.Sprintf(
"plugin %s is not active for %f seconds, it may be dead",
s.pluginUniqueIdentifier,
time.Since(s.lastActiveAt).Seconds(),
),
)
})
}
}
return nil
}
func (s *PluginInstance) Write(data []byte) error {
// write bytes into instance's stdin
_, err := s.inWriter.Write(data)
return err
}
// GracefulStop stops the instance gracefully
// wait for at most maxWaitTime to shutdown, forcefully kill it if timeout reached
func (s *PluginInstance) GracefulStop(maxWaitTime time.Duration) {
timeout := time.NewTimer(maxWaitTime)
defer timeout.Stop()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
s.l.Lock()
listeners := len(s.listener)
s.l.Unlock()
if listeners == 0 {
break
}
select {
case <-timeout.C:
// timeout reached, forcefully kill the instance
s.Stop()
return
case <-ticker.C:
// do nothing
}
}
// all listeners are closed, stop the instance
s.Stop()
}
func (s *PluginInstance) ID() string {
return s.instanceId
}