Ctrl K
ComunidadGuías…

internal/pluginhost/loader_unix_test.go

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

Archivo original ↗Preguntar a KiBot
//go:build cgo && (linux || darwin || freebsd)

package pluginhost

import (
	"context"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
	"syscall"
	"testing"
	"unsafe"

	"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
)

func testHostCallbackID(t *testing.T, client *dynamicLibraryClient) uintptr {
	t.Helper()
	if client == nil || client.hostCtx == nil {
		t.Fatal("plugin client host context is nil")
	}
	return *(*uintptr)(client.hostCtx)
}

func buildReloadTestPlugin(t *testing.T, version int, outPath string) {
	t.Helper()
	goTool, errGo := exec.LookPath("go")
	if errGo != nil {
		t.Skipf("go toolchain unavailable: %v", errGo)
	}
	sourceDir := filepath.Join("testdata", "loader_unix_reload_plugin")
	cmd := exec.Command(goTool, "build", "-buildmode=c-shared", "-o", outPath, ".")
	cmd.Dir = sourceDir
	cflags := "-DPLUGIN_VERSION=1"
	if version >= 1 {
		cflags = fmt.Sprintf("-DPLUGIN_VERSION=%d", version)
	}
	cmd.Env = append(os.Environ(), "CGO_ENABLED=1", "CGO_CFLAGS="+cflags)
	if output, errBuild := cmd.CombinedOutput(); errBuild != nil {
		t.Fatalf("build test plugin (v%d): %v\n%s", version, errBuild, output)
	}
}

func TestUnixPluginReloadAfterShutdownUsesReplacedFile(t *testing.T) {
	dir := t.TempDir()
	ext := ".so"
	if runtime.GOOS == "darwin" {
		ext = ".dylib"
	}
	pluginPath := filepath.Join(dir, "alpha"+ext)
	buildReloadTestPlugin(t, 1, pluginPath)

	host := New()
	file := pluginFile{ID: "alpha", Path: pluginPath, Version: "1"}
	client, errOpen := dynamicLibraryLoader{}.Open(file, host)
	if errOpen != nil {
		t.Fatalf("Open() v1 error = %v", errOpen)
	}
	first, okFirst := client.(*dynamicLibraryClient)
	if !okFirst {
		t.Fatalf("Open() client type = %T, want *dynamicLibraryClient", client)
	}
	firstHandle := first.handle
	if firstHandle == nil {
		t.Fatal("first client handle is nil")
	}
	resp, errCall := client.Call(context.Background(), "test.marker", nil)
	if errCall != nil {
		t.Fatalf("Call() v1 error = %v", errCall)
	}
	if string(resp) != "v1" {
		t.Fatalf("Call() v1 response = %q, want v1", resp)
	}
	firstHostCtx := first.hostCtx
	firstCallbackID := testHostCallbackID(t, first)

	client.Shutdown()

	// The Go runtime of a loaded plugin stays resident in the host process
	// even after the logical shutdown: unloading a Go c-shared image is not
	// safe, so the handle and host callback state must be retained while the
	// callback instance is closed for further work.
	if first.handle == nil || first.handle != firstHandle {
		t.Fatalf("handle after Shutdown() = %v, want retained handle %v (library must stay mapped)", first.handle, firstHandle)
	}
	if first.api.call != nil || first.api.free_buffer != nil || first.api.shutdown != nil {
		t.Fatal("plugin API table not cleared by Shutdown(); detached client could still be called")
	}
	if first.hostCtx != firstHostCtx {
		t.Fatalf("hostCtx after Shutdown() = %v, want retained %v for the still-resident runtime", first.hostCtx, firstHostCtx)
	}
	if _, okEntry := hostCallbackEntries.Load(firstCallbackID); okEntry {
		t.Fatal("host callback entry retained by Shutdown(); repeated reloads would pin the host forever")
	}
	if first.instance == nil || !first.instance.closed.Load() {
		t.Fatal("callback instance not closed by Shutdown(); stale runtime could still perform work")
	}

	// Replace the plugin binary under the same path (update) and load again
	// without restarting the process. It must map the new image, not reuse
	// the previously loaded one.
	buildReloadTestPlugin(t, 2, pluginPath)
	file.Version = "2"
	secondClient, errOpenSecond := dynamicLibraryLoader{}.Open(file, host)
	if errOpenSecond != nil {
		t.Fatalf("Open() v2 error = %v", errOpenSecond)
	}
	second, okSecond := secondClient.(*dynamicLibraryClient)
	if !okSecond {
		t.Fatalf("Open() second client type = %T, want *dynamicLibraryClient", secondClient)
	}
	respSecond, errCallSecond := secondClient.Call(context.Background(), "test.marker", nil)
	if errCallSecond != nil {
		t.Fatalf("Call() v2 error = %v", errCallSecond)
	}
	if string(respSecond) != "v2" {
		t.Fatalf("Call() v2 response = %q, want v2 (replaced plugin file was not freshly mapped)", respSecond)
	}
	if second.handle == firstHandle {
		t.Fatal("second handle reuses the first image after the plugin file changed")
	}
	baselineCalls := testShutdownCallCount(t, secondClient)

	secondClient.Shutdown()

	// Reopening without a content change must reuse the already resident
	// image instead of mapping a second copy of the same runtime, and the
	// shutdown above must have reached that image exactly once.
	thirdClient, errOpenThird := dynamicLibraryLoader{}.Open(file, host)
	if errOpenThird != nil {
		t.Fatalf("Open() third error = %v", errOpenThird)
	}
	third, okThird := thirdClient.(*dynamicLibraryClient)
	if !okThird {
		t.Fatalf("Open() third client type = %T, want *dynamicLibraryClient", thirdClient)
	}
	if third.handle != second.handle {
		t.Fatalf("third handle = %v, want reused resident handle %v", third.handle, second.handle)
	}
	respThird, errCallThird := thirdClient.Call(context.Background(), "test.marker", nil)
	if errCallThird != nil {
		t.Fatalf("Call() third error = %v", errCallThird)
	}
	if string(respThird) != "v2" {
		t.Fatalf("Call() third response = %q, want v2", respThird)
	}
	if gotCalls := testShutdownCallCount(t, thirdClient); gotCalls != baselineCalls+1 {
		t.Fatalf("plugin shutdown calls = %d, want baseline %d + 1", gotCalls, baselineCalls)
	}
	thirdClient.Shutdown()
}

func testShutdownCallCount(t *testing.T, client pluginClient) int {
	t.Helper()
	calls, errCalls := client.Call(context.Background(), "test.shutdown_calls", nil)
	if errCalls != nil {
		t.Fatalf("Call() shutdown_calls error = %v", errCalls)
	}
	count, errParse := strconv.Atoi(string(calls))
	if errParse != nil {
		t.Fatalf("parse shutdown calls %q: %v", calls, errParse)
	}
	return count
}

func TestUnixPluginLoaderFallsBackToSharedCacheWhenLocalShadowDirNoexec(t *testing.T) {
	dir := t.TempDir()
	pluginPath := filepath.Join(dir, "noexec-fallback"+testPluginLibraryExtension())
	buildReloadTestPlugin(t, 1, pluginPath)

	localShadowDir := filepath.Clean(filepath.Join(dir, shadowPluginLocalDirName))
	origDlopen := unixDlopen
	t.Cleanup(func() {
		unixDlopen = origDlopen
	})
	unixDlopen = func(path string) unsafe.Pointer {
		if filepath.Clean(filepath.Dir(path)) == localShadowDir {
			return nil
		}
		return origDlopen(path)
	}

	host := New()
	file := pluginFile{ID: "noexec-fallback", Path: pluginPath, Version: "1"}
	client, errOpen := dynamicLibraryLoader{}.Open(file, host)
	if errOpen != nil {
		t.Fatalf("Open() with simulated noexec local .shadow error = %v, want fallback success", errOpen)
	}
	defer client.Shutdown()
	dlc, okType := client.(*dynamicLibraryClient)
	if !okType {
		t.Fatalf("Open() client type = %T, want *dynamicLibraryClient", client)
	}
	t.Cleanup(func() {
		removeShadowPluginDir(filepath.Dir(dlc.tempPath))
	})
	wantFallbackDir, errFallbackDir := shadowPluginFallbackDir(pluginPath)
	if errFallbackDir != nil {
		t.Fatalf("shadowPluginFallbackDir() error = %v", errFallbackDir)
	}
	if filepath.Clean(filepath.Dir(dlc.tempPath)) != filepath.Clean(wantFallbackDir) {
		t.Fatalf("loaded shadow dir = %q, want fallback dir %q", filepath.Dir(dlc.tempPath), wantFallbackDir)
	}
	resp, errCall := client.Call(context.Background(), "test.marker", nil)
	if errCall != nil {
		t.Fatalf("Call() fallback plugin error = %v", errCall)
	}
	if string(resp) != "v1" {
		t.Fatalf("Call() fallback plugin response = %q, want v1", resp)
	}
}

func TestUnixPluginLoaderLoadsFromReadOnlyPopulatedLocalShadowDir(t *testing.T) {
	dir := t.TempDir()
	pluginPath := filepath.Join(dir, "readonly-shadow"+testPluginLibraryExtension())
	buildReloadTestPlugin(t, 1, pluginPath)
	file := pluginFile{ID: "readonly-shadow", Path: pluginPath, Version: "1"}

	shadowPath, errShadow := shadowCopyPlugin(file)
	if errShadow != nil {
		t.Fatalf("shadowCopyPlugin() pre-populate error = %v", errShadow)
	}
	localShadowDir := filepath.Dir(shadowPath)
	probedPluginShadows.Delete(filepath.Base(shadowPath))

	if errChmod := os.Chmod(localShadowDir, 0o500); errChmod != nil {
		t.Fatalf("Chmod(localShadowDir) error = %v", errChmod)
	}
	t.Cleanup(func() {
		_ = os.Chmod(localShadowDir, 0o700)
	})

	host := New()
	client, errOpen := dynamicLibraryLoader{}.Open(file, host)
	if errOpen != nil {
		t.Fatalf("Open() with read-only populated .shadow error = %v, want success", errOpen)
	}
	defer client.Shutdown()
	resp, errCall := client.Call(context.Background(), "test.marker", nil)
	if errCall != nil {
		t.Fatalf("Call() plugin error = %v", errCall)
	}
	if string(resp) != "v1" {
		t.Fatalf("Call() plugin response = %q, want v1", resp)
	}
}

func TestUnixPluginOpenFailureKeepsImageUsableOnRetry(t *testing.T) {
	dir := t.TempDir()
	pluginPath := filepath.Join(dir, "failing"+testPluginLibraryExtension())
	buildReloadTestPlugin(t, 3, pluginPath)

	host := New()
	file := pluginFile{ID: "failing", Path: pluginPath, Version: "3"}
	loader := dynamicLibraryLoader{}
	if _, errOpen := loader.Open(file, host); errOpen == nil {
		t.Fatal("Open() with failing plugin init succeeded, want error")
	}

	// The failed load must not unload the image: retrying the same file
	// must fail the same way instead of crashing or spinning, and loading
	// a fixed build under the same path must succeed afterwards.
	if _, errOpen := loader.Open(file, host); errOpen == nil {
		t.Fatal("Open() retry with failing plugin init succeeded, want error")
	}

	buildReloadTestPlugin(t, 1, pluginPath)
	file.Version = "1"
	client, errOpen := loader.Open(file, host)
	if errOpen != nil {
		t.Fatalf("Open() fixed plugin error = %v", errOpen)
	}
	resp, errCall := client.Call(context.Background(), "test.marker", nil)
	if errCall != nil {
		t.Fatalf("Call() fixed plugin error = %v", errCall)
	}
	if string(resp) != "v1" {
		t.Fatalf("Call() fixed plugin response = %q, want v1", resp)
	}
	client.Shutdown()
}

func TestUnixPluginLoaderIsolatesSigillOnDlopen(t *testing.T) {
	dir := t.TempDir()
	pluginPath := filepath.Join(dir, "broken"+testPluginLibraryExtension())
	buildReloadTestPlugin(t, 4, pluginPath)

	host := New()
	file := pluginFile{ID: "broken", Path: pluginPath, Version: "4"}
	loader := dynamicLibraryLoader{}
	_, errOpen := loader.Open(file, host)
	if errOpen == nil {
		t.Fatal("Open() with SIGILL plugin succeeded, want error")
	}
	if !strings.Contains(errOpen.Error(), "preflight probe terminated by SIGILL: illegal instruction") {
		t.Fatalf("Open() error = %v, want preflight SIGILL diagnostic", errOpen)
	}

	// Retrying the same crashing plugin binary must fail cleanly without
	// crashing the host process.
	_, errRetry := loader.Open(file, host)
	if errRetry == nil {
		t.Fatal("Open() retry with SIGILL plugin succeeded, want error")
	}
	if !strings.Contains(errRetry.Error(), "preflight probe terminated by SIGILL: illegal instruction") {
		t.Fatalf("Open() retry error = %v, want cached preflight SIGILL diagnostic", errRetry)
	}

	// Applying host config with the crashing plugin enabled must isolate
	// the failure and keep the host process alive.
	host.ApplyConfig(context.Background(), &config.Config{
		Plugins: config.PluginsConfig{
			Enabled: true,
			Dir:     dir,
			Configs: enabledPluginConfigs("broken"),
		},
	})
	if host.PluginLoaded("broken") {
		t.Fatal("PluginLoaded(broken) = true, want false after preflight SIGILL failure")
	}

	// Replacing the broken binary with a valid build under the same path
	// must succeed without needing to restart the host process.
	buildReloadTestPlugin(t, 1, pluginPath)
	file.Version = "1"
	client, errFixed := loader.Open(file, host)
	if errFixed != nil {
		t.Fatalf("Open() fixed plugin after SIGILL error = %v", errFixed)
	}
	defer client.Shutdown()
	resp, errCall := client.Call(context.Background(), "test.marker", nil)
	if errCall != nil {
		t.Fatalf("Call() fixed plugin error = %v", errCall)
	}
	if string(resp) != "v1" {
		t.Fatalf("Call() fixed plugin response = %q, want v1", resp)
	}
}

func TestUnixPluginLoaderDoesNotCacheTransientProbeStartFailure(t *testing.T) {
	dir := t.TempDir()
	pluginPath := filepath.Join(dir, "transient"+testPluginLibraryExtension())
	buildReloadTestPlugin(t, 1, pluginPath)
	probedPluginShadows.Range(func(key, _ any) bool {
		probedPluginShadows.Delete(key)
		return true
	})

	origProbeExec := pluginProbeExecutable
	t.Cleanup(func() {
		pluginProbeExecutable = origProbeExec
	})
	pluginProbeExecutable = func() (string, error) {
		return "", errors.New("transient executable lookup failure")
	}

	host := New()
	file := pluginFile{ID: "transient", Path: pluginPath, Version: "1"}
	loader := dynamicLibraryLoader{}
	if _, errOpen := loader.Open(file, host); errOpen == nil {
		t.Fatal("Open() with transient probe executable failure succeeded, want error")
	}

	// Also verify a spawn failure (non-existent executable) is not cached.
	pluginProbeExecutable = func() (string, error) {
		return filepath.Join(dir, "nonexistent-probe-binary"), nil
	}
	if _, errOpen := loader.Open(file, host); errOpen == nil {
		t.Fatal("Open() with missing probe binary succeeded, want error")
	}

	// Once the transient failure resolves, the same plugin binary must load
	// without needing a content change or process restart.
	pluginProbeExecutable = origProbeExec
	client, errOpen := loader.Open(file, host)
	if errOpen != nil {
		t.Fatalf("Open() after transient probe failure recovered = %v, want nil", errOpen)
	}
	defer client.Shutdown()
	resp, errCall := client.Call(context.Background(), "test.marker", nil)
	if errCall != nil {
		t.Fatalf("Call() recovered plugin error = %v", errCall)
	}
	if string(resp) != "v1" {
		t.Fatalf("Call() recovered plugin response = %q, want v1", resp)
	}
}

func TestUnixPluginLoaderDoesNotCacheUnloadedProbeSuccess(t *testing.T) {
	dir := t.TempDir()
	pluginPath := filepath.Join(dir, "unloaded"+testPluginLibraryExtension())
	if errWrite := os.WriteFile(pluginPath, []byte("not-a-valid-shared-library"), 0o644); errWrite != nil {
		t.Fatalf("WriteFile() error = %v", errWrite)
	}
	file := pluginFile{ID: "unloaded", Path: pluginPath, Version: "1"}
	loadPath, errShadow := shadowCopyPlugin(file)
	if errShadow != nil {
		t.Fatalf("shadowCopyPlugin() error = %v", errShadow)
	}
	cacheKey := filepath.Base(loadPath)
	removeShadowPlugin(loadPath)
	probedPluginShadows.Delete(cacheKey)

	host := New()
	loader := dynamicLibraryLoader{}
	if _, errOpen := loader.Open(file, host); errOpen == nil {
		t.Fatal("Open() with invalid shared library succeeded, want error")
	}
	if _, cached := probedPluginShadows.Load(cacheKey); cached {
		t.Fatalf("probedPluginShadows cached %q after dlopen returned NULL; subsequent retry would skip preflight probe", cacheKey)
	}
}

func TestUnixPluginLoaderRejectsPrematureExitZeroInConstructor(t *testing.T) {
	dir := t.TempDir()
	pluginPath := filepath.Join(dir, "early-exit"+testPluginLibraryExtension())
	buildReloadTestPlugin(t, 5, pluginPath)

	host := New()
	file := pluginFile{ID: "early-exit", Path: pluginPath, Version: "5"}
	loader := dynamicLibraryLoader{}
	_, errOpen := loader.Open(file, host)
	if errOpen == nil {
		t.Fatal("Open() with premature _exit(0) plugin succeeded, want error")
	}
	if !strings.Contains(errOpen.Error(), "preflight probe exited prematurely before completing initialization") {
		t.Fatalf("Open() error = %v, want premature exit diagnostic", errOpen)
	}
}

func TestUnixPluginLoaderDoesNotCacheSigkillDuringProbe(t *testing.T) {
	dir := t.TempDir()
	pluginPath := filepath.Join(dir, "killed"+testPluginLibraryExtension())
	buildReloadTestPlugin(t, 6, pluginPath)

	file := pluginFile{ID: "killed", Path: pluginPath, Version: "6"}
	loadPath, errShadow := shadowCopyPlugin(file)
	if errShadow != nil {
		t.Fatalf("shadowCopyPlugin() error = %v", errShadow)
	}
	cacheKey := filepath.Base(loadPath)
	removeShadowPlugin(loadPath)
	probedPluginShadows.Delete(cacheKey)

	host := New()
	loader := dynamicLibraryLoader{}
	if _, errOpen := loader.Open(file, host); errOpen == nil {
		t.Fatal("Open() with SIGKILL plugin succeeded, want error")
	}
	if _, cached := probedPluginShadows.Load(cacheKey); cached {
		t.Fatalf("probedPluginShadows cached %q after SIGKILL; transient external kill must not be permanently cached", cacheKey)
	}
}

func TestUnixPluginProbeDoesNotBlockOnInheritedStdioGrandchild(t *testing.T) {
	dir := t.TempDir()
	pluginPath := filepath.Join(dir, "grandchild"+testPluginLibraryExtension())
	buildReloadTestPlugin(t, 7, pluginPath)

	pidFile := filepath.Join(dir, "grandchild.pid")
	t.Setenv("CLIPROXY_TEST_GRANDCHILD_PID_FILE", pidFile)
	t.Cleanup(func() {
		raw, errRead := os.ReadFile(pidFile)
		if errRead != nil {
			return
		}
		pid, errParse := strconv.Atoi(strings.TrimSpace(string(raw)))
		if errParse == nil && pid > 0 {
			_ = syscall.Kill(pid, syscall.SIGKILL)
		}
	})

	file := pluginFile{ID: "grandchild", Path: pluginPath, Version: "7"}
	loadPath, errShadow := shadowCopyPlugin(file)
	if errShadow != nil {
		t.Fatalf("shadowCopyPlugin() error = %v", errShadow)
	}
	t.Cleanup(func() {
		removeShadowPlugin(loadPath)
	})
	cacheKey := filepath.Base(loadPath)
	probedPluginShadows.Delete(cacheKey)

	if errProbe := probePluginLibrary(file.Path, loadPath); errProbe != nil {
		t.Fatalf("probePluginLibrary() error = %v, want nil without blocking on grandchild stdio", errProbe)
	}
	raw, errRead := os.ReadFile(pidFile)
	if errRead != nil {
		t.Fatalf("ReadFile(%s) error = %v, want grandchild pid recorded", pidFile, errRead)
	}
	pid, errParse := strconv.Atoi(strings.TrimSpace(string(raw)))
	if errParse != nil || pid <= 0 {
		t.Fatalf("grandchild pid = %q (%v), want positive pid", string(raw), errParse)
	}
}

func TestUnixPluginProbeStoredHostCallbackAndLargeStderrSubprocess(t *testing.T) {
	dir := t.TempDir()
	pluginPath := filepath.Join(dir, "noisy-shutdown"+testPluginLibraryExtension())
	buildReloadTestPlugin(t, 8, pluginPath)

	file := pluginFile{ID: "noisy-shutdown", Path: pluginPath, Version: "8"}
	loadPath, errShadow := shadowCopyPlugin(file)
	if errShadow != nil {
		t.Fatalf("shadowCopyPlugin() error = %v", errShadow)
	}
	t.Cleanup(func() {
		removeShadowPlugin(loadPath)
	})
	cacheKey := filepath.Base(loadPath)
	probedPluginShadows.Delete(cacheKey)

	errProbe := probePluginLibrary(file.Path, loadPath)
	if errProbe == nil {
		t.Fatal("probePluginLibrary() with noisy _exit(1) shutdown succeeded, want error")
	}
	if len(errProbe.Error()) > maxPluginProbeOutputBytes+512 {
		t.Fatalf("probePluginLibrary() error length = %d, exceeds bounded limit", len(errProbe.Error()))
	}
}

func TestLimitedProbeBufferCapsRetainedOutputWithoutBlocking(t *testing.T) {
	buf := newLimitedProbeBuffer(16)
	n1, err1 := buf.Write([]byte("0123456789"))
	if err1 != nil || n1 != 10 {
		t.Fatalf("first Write() = (%d, %v), want (10, nil)", n1, err1)
	}
	n2, err2 := buf.Write([]byte("abcdefghijklmnop"))
	if err2 != nil || n2 != 16 {
		t.Fatalf("second Write() = (%d, %v), want (16, nil)", n2, err2)
	}
	if got := buf.String(); got != "0123456789abcdef" {
		t.Fatalf("String() = %q, want %q", got, "0123456789abcdef")
	}
}