internal/pluginhost/loader_unix.go
Rama main · réplica de lectura del repositorio original.
//go:build cgo && (linux || darwin || freebsd)
package pluginhost
/*
#cgo linux LDFLAGS: -ldl
#cgo freebsd LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdint.h>
#include <stdlib.h>
#include <unistd.h>
typedef struct {
void* ptr;
size_t len;
} cliproxy_buffer;
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_host_free_fn)(void*, size_t);
typedef struct {
uint32_t abi_version;
void* host_ctx;
cliproxy_host_call_fn call;
cliproxy_host_free_fn free_buffer;
} cliproxy_host_api;
typedef int (*cliproxy_plugin_call_fn)(const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
typedef void (*cliproxy_plugin_shutdown_fn)(void);
typedef struct {
uint32_t abi_version;
cliproxy_plugin_call_fn call;
cliproxy_plugin_free_fn free_buffer;
cliproxy_plugin_shutdown_fn shutdown;
} cliproxy_plugin_api;
typedef int (*cliproxy_plugin_init_fn)(const cliproxy_host_api*, cliproxy_plugin_api*);
extern int cliproxyHostCall(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
extern void cliproxyHostFree(void*, size_t);
static void* cliproxy_dlopen(const char* path) {
return dlopen(path, RTLD_NOW | RTLD_LOCAL);
}
static void* cliproxy_dlsym(void* handle, const char* name) {
return dlsym(handle, name);
}
static const char* cliproxy_dlerror(void) {
return dlerror();
}
static int cliproxy_call_init(void* fn, const cliproxy_host_api* host, cliproxy_plugin_api* plugin) {
return ((cliproxy_plugin_init_fn)fn)(host, plugin);
}
static int cliproxy_call_plugin(cliproxy_plugin_call_fn fn, const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
return fn(method, request, request_len, response);
}
static void cliproxy_free_plugin_buffer(cliproxy_plugin_free_fn fn, void* ptr, size_t len) {
fn(ptr, len);
}
static void cliproxy_shutdown_plugin(cliproxy_plugin_shutdown_fn fn) {
fn();
}
static void cliproxy_set_host_api(cliproxy_host_api* api, uint32_t abi_version, void* host_ctx) {
api->abi_version = abi_version;
api->host_ctx = host_ctx;
api->call = cliproxyHostCall;
api->free_buffer = cliproxyHostFree;
}
static int cliproxy_probe_host_call(void* host_ctx, const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
(void)host_ctx;
(void)method;
(void)request;
(void)request_len;
if (response != NULL) {
response->ptr = NULL;
response->len = 0;
}
return 1;
}
static void cliproxy_probe_host_free(void* ptr, size_t len) {
(void)ptr;
(void)len;
}
static int cliproxy_probe_init_and_shutdown(void* fn, uint32_t abi_version) {
static cliproxy_host_api host_api;
cliproxy_plugin_api plugin_api = {0};
host_api.abi_version = abi_version;
host_api.host_ctx = NULL;
host_api.call = cliproxy_probe_host_call;
host_api.free_buffer = cliproxy_probe_host_free;
int rc = ((cliproxy_plugin_init_fn)fn)(&host_api, &plugin_api);
if (rc == 0 && plugin_api.shutdown != NULL) {
plugin_api.shutdown();
}
return rc;
}
static void cliproxy_probe_exit(int code) {
_exit(code);
}
*/
import "C"
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"unsafe"
)
const (
pluginDlopenProbeEnv = "CLIPROXY_PLUGIN_DLOPEN_PROBE"
pluginDlopenProbeStatusEnv = "CLIPROXY_PLUGIN_DLOPEN_PROBE_STATUS"
probeStatusStarted = "started"
probeStatusLoaded = "loaded"
probeStatusDlopenNull = "dlopen_null"
maxPluginProbeOutputBytes = 64 * 1024
)
var (
hostCallbackID atomic.Uintptr
hostCallbackEntries sync.Map
probedPluginShadows sync.Map
pluginProbeExecutable = os.Executable
unixDlopen = defaultUnixDlopen
)
func defaultUnixDlopen(path string) unsafe.Pointer {
cPath := C.CString(path)
defer C.free(unsafe.Pointer(cPath))
return C.cliproxy_dlopen(cPath)
}
func init() {
if probePath := os.Getenv(pluginDlopenProbeEnv); probePath != "" {
runPluginDlopenProbe(probePath)
}
}
type dynamicLibraryLoader struct{}
type dynamicLibraryClient struct {
handle unsafe.Pointer
tempPath string
hostAPI *C.cliproxy_host_api
hostCtx unsafe.Pointer
api C.cliproxy_plugin_api
host *Host
pluginID string
instance *hostCallbackInstance
}
func defaultPluginLoader() pluginLoader {
return dynamicLibraryLoader{}
}
func (dynamicLibraryLoader) Open(file pluginFile, host *Host) (pluginClient, error) {
loadPath, releaseGuard, errShadow := acquireShadowPluginLoad(file)
if errShadow != nil {
return nil, errShadow
}
handle, loadPath, errOpen := openUnixShadowPluginWithFallback(file, loadPath, releaseGuard)
if errOpen != nil {
return nil, errOpen
}
// From here on the image must never be unloaded: dlopen may already
// have started the plugin runtime, and a Go c-shared runtime cannot be
// removed from the host process safely. Failure paths below only detach
// the client logically and deliberately leak the mapped image together
// with any host callback state the runtime may reference.
cSymbol := C.CString("cliproxy_plugin_init")
initSymbol := C.cliproxy_dlsym(handle, cSymbol)
C.free(unsafe.Pointer(cSymbol))
if initSymbol == nil {
return nil, fmt.Errorf("missing cliproxy_plugin_init: %s", dlerrorString())
}
hostAPI := (*C.cliproxy_host_api)(C.malloc(C.size_t(unsafe.Sizeof(C.cliproxy_host_api{}))))
if hostAPI == nil {
return nil, fmt.Errorf("allocate host api")
}
hostCtx := C.malloc(C.size_t(unsafe.Sizeof(C.uintptr_t(0))))
if hostCtx == nil {
return nil, fmt.Errorf("allocate host context")
}
id := hostCallbackID.Add(1)
instance := &hostCallbackInstance{}
host.registerHostCallbackInstance(file.ID, instance)
*(*C.uintptr_t)(hostCtx) = C.uintptr_t(id)
hostCallbackEntries.Store(id, dynamicHostCallbackEntry{host: host, pluginID: file.ID, instance: instance})
C.cliproxy_set_host_api(hostAPI, C.uint32_t(pluginHostABIVersion), hostCtx)
client := &dynamicLibraryClient{
handle: handle,
tempPath: loadPath,
hostAPI: hostAPI,
hostCtx: hostCtx,
host: host,
pluginID: file.ID,
instance: instance,
}
rc := C.cliproxy_call_init(initSymbol, hostAPI, &client.api)
if rc != 0 {
client.closeAfterOpenFailure()
return nil, fmt.Errorf("cliproxy_plugin_init returned %d", int(rc))
}
if uint32(client.api.abi_version) != pluginHostABIVersion {
client.closeAfterOpenFailure()
return nil, fmt.Errorf("plugin ABI version %d is not supported", uint32(client.api.abi_version))
}
if client.api.call == nil || client.api.free_buffer == nil {
client.closeAfterOpenFailure()
return nil, fmt.Errorf("plugin function table is incomplete")
}
return client, nil
}
func (c *dynamicLibraryClient) callbackInstance() *hostCallbackInstance {
if c == nil {
return nil
}
return c.instance
}
func (c *dynamicLibraryClient) Call(ctx context.Context, method string, request []byte) ([]byte, error) {
if c == nil || c.api.call == nil {
return nil, fmt.Errorf("plugin client is closed")
}
if ctx != nil {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
}
cMethod := C.CString(method)
defer C.free(unsafe.Pointer(cMethod))
var cRequest unsafe.Pointer
if len(request) > 0 {
cRequest = C.CBytes(request)
defer C.free(cRequest)
}
var response C.cliproxy_buffer
rc := C.cliproxy_call_plugin(c.api.call, cMethod, (*C.uint8_t)(cRequest), C.size_t(len(request)), &response)
var out []byte
if response.ptr != nil && response.len > 0 {
out = C.GoBytes(response.ptr, C.int(response.len))
}
if response.ptr != nil {
C.cliproxy_free_plugin_buffer(c.api.free_buffer, response.ptr, response.len)
}
if rc != 0 {
if isPluginErrorEnvelope(out) {
return out, nil
}
return nil, fmt.Errorf("plugin call %s returned %d: %s", method, int(rc), string(out))
}
return out, nil
}
func (c *dynamicLibraryClient) Shutdown() {
c.close()
}
func (c *dynamicLibraryClient) closeAfterOpenFailure() {
c.close()
}
// close detaches the client logically without unloading the plugin image.
// Go plugin libraries are not safe to hot-unload from the host process: the
// plugin runtime, its threads and its signal handlers stay resident after
// dlclose, so a later load in the same process forwards signals into
// unmapped memory and spins. This also applies when initialization failed,
// because dlopen may already have started the runtime. The plugin was
// loaded from a shadow copy, so keeping the image mapped does not block
// deleting or replacing the source artifact.
func (c *dynamicLibraryClient) close() {
if c == nil {
return
}
if c.host != nil {
c.host.closeHostHTTPCallbackInstance(c.pluginID, c.instance)
c.host = nil
}
if c.api.shutdown != nil {
C.cliproxy_shutdown_plugin(c.api.shutdown)
c.api.shutdown = nil
}
c.api.call = nil
c.api.free_buffer = nil
if c.hostCtx != nil {
// Drop the callback registration so repeated reloads do not pin the
// host forever. The C host context and host API block themselves are
// deliberately leaked: the resident plugin runtime may still pass
// the context back into cliproxyHostCall, which reads the callback
// id from it before failing the lookup.
id := uintptr(*(*C.uintptr_t)(c.hostCtx))
hostCallbackEntries.Delete(id)
}
}
func openUnixShadowPluginWithFallback(file pluginFile, loadPath string, releaseGuard func()) (unsafe.Pointer, string, error) {
errProbe := probePluginLibrary(file.Path, loadPath)
if errProbe != nil {
releaseGuard()
if _, cached := probedPluginShadows.Load(filepath.Base(loadPath)); cached {
return nil, "", errProbe
}
if fallbackHandle, fallbackPath, okFallback := tryOpenUnixFallbackShadowPlugin(file, loadPath); okFallback {
return fallbackHandle, fallbackPath, nil
}
return nil, "", errProbe
}
handle := unixDlopen(loadPath)
firstErr := dlerrorString()
releaseGuard()
if handle != nil {
return handle, loadPath, nil
}
probedPluginShadows.Delete(filepath.Base(loadPath))
if fallbackHandle, fallbackPath, okFallback := tryOpenUnixFallbackShadowPlugin(file, loadPath); okFallback {
return fallbackHandle, fallbackPath, nil
}
return nil, "", fmt.Errorf("dlopen %s: %s", file.Path, firstErr)
}
func tryOpenUnixFallbackShadowPlugin(file pluginFile, failedLoadPath string) (unsafe.Pointer, string, bool) {
fallbackPath, releaseFallbackGuard, errFallback := acquireShadowPluginFallbackLoad(file, failedLoadPath)
if errFallback != nil {
return nil, "", false
}
if errFallbackProbe := probePluginLibrary(file.Path, fallbackPath); errFallbackProbe != nil {
releaseFallbackGuard()
return nil, "", false
}
fallbackHandle := unixDlopen(fallbackPath)
releaseFallbackGuard()
if fallbackHandle != nil {
return fallbackHandle, fallbackPath, true
}
probedPluginShadows.Delete(filepath.Base(fallbackPath))
return nil, "", false
}
func dlerrorString() string {
errText := C.cliproxy_dlerror()
if errText == nil {
return ""
}
return C.GoString(errText)
}
func runPluginDlopenProbe(probePath string) {
statusPath := os.Getenv(pluginDlopenProbeStatusEnv)
_ = os.Unsetenv(pluginDlopenProbeEnv)
_ = os.Unsetenv(pluginDlopenProbeStatusEnv)
writeProbeStatus(statusPath, probeStatusStarted)
cPath := C.CString(probePath)
handle := C.cliproxy_dlopen(cPath)
C.free(unsafe.Pointer(cPath))
if handle == nil {
writeProbeStatus(statusPath, probeStatusDlopenNull)
C.cliproxy_probe_exit(0)
return
}
cSymbol := C.CString("cliproxy_plugin_init")
initSymbol := C.cliproxy_dlsym(handle, cSymbol)
C.free(unsafe.Pointer(cSymbol))
if initSymbol != nil {
_ = C.cliproxy_probe_init_and_shutdown(initSymbol, C.uint32_t(pluginHostABIVersion))
}
writeProbeStatus(statusPath, probeStatusLoaded)
C.cliproxy_probe_exit(0)
}
func writeProbeStatus(statusPath, status string) {
if statusPath == "" {
return
}
_ = os.WriteFile(statusPath, []byte(status), 0o600)
}
type limitedProbeBuffer struct {
mu sync.Mutex
buf bytes.Buffer
limit int
}
func newLimitedProbeBuffer(limit int) *limitedProbeBuffer {
if limit <= 0 {
limit = maxPluginProbeOutputBytes
}
return &limitedProbeBuffer{limit: limit}
}
func (b *limitedProbeBuffer) Write(p []byte) (int, error) {
total := len(p)
if b == nil || b.limit <= 0 {
return total, nil
}
b.mu.Lock()
remaining := b.limit - b.buf.Len()
if remaining > 0 {
if len(p) > remaining {
p = p[:remaining]
}
_, _ = b.buf.Write(p)
}
b.mu.Unlock()
return total, nil
}
func (b *limitedProbeBuffer) String() string {
if b == nil {
return ""
}
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
func probePluginLibrary(sourcePath, loadPath string) error {
cacheKey := filepath.Base(loadPath)
if cached, ok := probedPluginShadows.Load(cacheKey); ok {
if detail, _ := cached.(string); detail != "" {
return fmt.Errorf("dlopen %s: %s", sourcePath, detail)
}
return nil
}
execPath, errExec := pluginProbeExecutable()
if errExec != nil {
return fmt.Errorf("dlopen %s: resolve preflight probe executable: %w", sourcePath, errExec)
}
if strings.TrimSpace(execPath) == "" {
execPath = os.Args[0]
}
statusDir, errStatusDir := shadowPluginDir()
if errStatusDir != nil {
statusDir = os.TempDir()
}
statusFile, errStatus := os.CreateTemp(statusDir, ".cliproxy-probe-*.status")
if errStatus != nil {
return fmt.Errorf("dlopen %s: create preflight probe status file: %w", sourcePath, errStatus)
}
statusPath := statusFile.Name()
_ = statusFile.Close()
defer func() {
_ = os.Remove(statusPath)
}()
devNull, errDevNull := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if errDevNull != nil {
return fmt.Errorf("dlopen %s: open %s: %w", sourcePath, os.DevNull, errDevNull)
}
defer func() {
_ = devNull.Close()
}()
// Use an explicit os.Pipe (*os.File) for Stderr so os/exec does not spawn
// its own uncancelable pipe-drain goroutine. Closing stderrReader as soon
// as cmd.Wait() returns ensures a grandchild process that inherited Stderr
// neither blocks Wait() nor writes unbounded data to disk.
stderrReader, stderrWriter, errPipe := os.Pipe()
if errPipe != nil {
return fmt.Errorf("dlopen %s: create preflight probe pipe: %w", sourcePath, errPipe)
}
stderrBuf := newLimitedProbeBuffer(maxPluginProbeOutputBytes)
stderrDone := make(chan struct{})
go func() {
defer close(stderrDone)
_, _ = io.Copy(stderrBuf, stderrReader)
}()
cmd := exec.Command(execPath)
cmd.Env = append(
os.Environ(),
pluginDlopenProbeEnv+"="+loadPath,
pluginDlopenProbeStatusEnv+"="+statusPath,
)
cmd.Stdout = devNull
cmd.Stderr = stderrWriter
errStart := cmd.Start()
_ = stderrWriter.Close()
if errStart != nil {
_ = stderrReader.Close()
<-stderrDone
return fmt.Errorf("dlopen %s: preflight probe failed: %w", sourcePath, errStart)
}
errRun := cmd.Wait()
_ = stderrReader.Close()
<-stderrDone
statusBytes, _ := os.ReadFile(statusPath)
statusText := strings.TrimSpace(string(statusBytes))
stderrText := stderrBuf.String()
if errRun == nil {
switch statusText {
case probeStatusLoaded:
probedPluginShadows.Store(cacheKey, "")
return nil
case probeStatusDlopenNull:
return nil
case probeStatusStarted:
detail := "preflight probe exited prematurely before completing initialization"
if firstLine := firstNonEmptyLine(stderrText); firstLine != "" {
detail = fmt.Sprintf("%s: %s", detail, firstLine)
}
probedPluginShadows.Store(cacheKey, detail)
return fmt.Errorf("dlopen %s: %s", sourcePath, detail)
default:
return fmt.Errorf("dlopen %s: preflight probe did not complete", sourcePath)
}
}
detail := describePluginProbeFailure(errRun, stderrText)
if statusText == probeStatusStarted && isCacheablePluginProbeFailure(errRun, stderrText) {
probedPluginShadows.Store(cacheKey, detail)
}
return fmt.Errorf("dlopen %s: %s", sourcePath, detail)
}
func isCacheablePluginProbeFailure(errRun error, stderrText string) bool {
var exitErr *exec.ExitError
if errors.As(errRun, &exitErr) {
if status, okStatus := exitErr.Sys().(syscall.WaitStatus); okStatus && status.Signaled() {
switch status.Signal() {
case syscall.SIGILL, syscall.SIGSEGV, syscall.SIGBUS, syscall.SIGFPE, syscall.SIGABRT, syscall.SIGTRAP, syscall.SIGSYS:
return true
default:
return false
}
}
}
lowerStderr := strings.ToLower(stderrText)
for _, token := range []string{
"sigill",
"illegal instruction",
"sigsegv",
"segmentation violation",
"sigbus",
"bus error",
"sigfpe",
"floating-point exception",
"sigabrt",
"sigtrap",
"panic:",
"fatal error:",
} {
if strings.Contains(lowerStderr, token) {
return true
}
}
return false
}
func describePluginProbeFailure(errRun error, stderrText string) string {
firstLine := firstNonEmptyLine(stderrText)
lowerStderr := strings.ToLower(stderrText)
var exitErr *exec.ExitError
if errors.As(errRun, &exitErr) {
if status, okStatus := exitErr.Sys().(syscall.WaitStatus); okStatus && status.Signaled() {
sig := status.Signal()
if sig == syscall.SIGILL {
return "preflight probe terminated by SIGILL: illegal instruction (possible CPU instruction set mismatch)"
}
if firstLine != "" {
return fmt.Sprintf("preflight probe terminated by signal %s: %s", sig, firstLine)
}
return fmt.Sprintf("preflight probe terminated by signal %s", sig)
}
}
if strings.Contains(stderrText, "SIGILL") || strings.Contains(lowerStderr, "illegal instruction") {
return "preflight probe terminated by SIGILL: illegal instruction (possible CPU instruction set mismatch)"
}
if firstLine != "" {
return fmt.Sprintf("preflight probe failed (%v): %s", errRun, firstLine)
}
return fmt.Sprintf("preflight probe failed: %v", errRun)
}
func firstNonEmptyLine(text string) string {
for _, line := range strings.Split(text, "\n") {
if trimmed := strings.TrimSpace(line); trimmed != "" {
return trimmed
}
}
return ""
}