diff --git a/pkg/manager/lifecycle_test.go b/pkg/manager/lifecycle_test.go new file mode 100644 index 0000000..520f445 --- /dev/null +++ b/pkg/manager/lifecycle_test.go @@ -0,0 +1,220 @@ +package manager_test + +import ( + "llamactl/pkg/backends" + "llamactl/pkg/instance" + "llamactl/pkg/manager" + "sync" + "testing" + "time" +) + +func TestInstanceTimeoutLogic(t *testing.T) { + testManager := createTestManager() + defer testManager.Shutdown() + + idleTimeout := 1 // 1 minute + inst := createInstanceWithTimeout(t, testManager, "timeout-test", "/path/to/model.gguf", &idleTimeout) + + // Test timeout logic with mock time provider + mockTime := NewMockTimeProvider(time.Now()) + inst.SetTimeProvider(mockTime) + + // Set instance to running state so timeout logic can work + inst.SetStatus(instance.Running) + defer inst.SetStatus(instance.Stopped) + + // Update last request time + inst.UpdateLastRequestTime() + + // Initially should not timeout (just updated) + if inst.ShouldTimeout() { + t.Error("Instance should not timeout immediately after request") + } + + // Advance time to trigger timeout + mockTime.SetTime(time.Now().Add(2 * time.Minute)) + + // Now it should timeout + if !inst.ShouldTimeout() { + t.Error("Instance should timeout after idle period") + } +} + +func TestInstanceWithoutTimeoutNeverExpires(t *testing.T) { + testManager := createTestManager() + defer testManager.Shutdown() + + noTimeoutInst := createInstanceWithTimeout(t, testManager, "no-timeout-test", "/path/to/model.gguf", nil) + + mockTime := NewMockTimeProvider(time.Now()) + noTimeoutInst.SetTimeProvider(mockTime) + noTimeoutInst.SetStatus(instance.Running) + defer noTimeoutInst.SetStatus(instance.Stopped) + + noTimeoutInst.UpdateLastRequestTime() + + // Advance time significantly + mockTime.SetTime(mockTime.Now().Add(24 * time.Hour)) + + // Even with time advanced, should not timeout + if noTimeoutInst.ShouldTimeout() { + t.Error("Instance without timeout configuration should never timeout") + } +} + +func TestEvictLRUInstance_Success(t *testing.T) { + manager := createTestManager() + defer manager.Shutdown() + + // Create 3 instances with idle timeout enabled (value doesn't matter for LRU logic) + validTimeout := 1 + inst1 := createInstanceWithTimeout(t, manager, "instance-1", "/path/to/model1.gguf", &validTimeout) + inst2 := createInstanceWithTimeout(t, manager, "instance-2", "/path/to/model2.gguf", &validTimeout) + inst3 := createInstanceWithTimeout(t, manager, "instance-3", "/path/to/model3.gguf", &validTimeout) + + // Set up mock time and set instances to running + mockTime := NewMockTimeProvider(time.Now()) + inst1.SetTimeProvider(mockTime) + inst2.SetTimeProvider(mockTime) + inst3.SetTimeProvider(mockTime) + + inst1.SetStatus(instance.Running) + inst2.SetStatus(instance.Running) + inst3.SetStatus(instance.Running) + defer func() { + // Clean up - ensure all instances are stopped + for _, inst := range []*instance.Instance{inst1, inst2, inst3} { + if inst.IsRunning() { + inst.SetStatus(instance.Stopped) + } + } + }() + + // Set different last request times (oldest to newest) + // inst1: oldest (will be evicted) + inst1.UpdateLastRequestTime() + + mockTime.SetTime(mockTime.Now().Add(1 * time.Minute)) + inst2.UpdateLastRequestTime() + + mockTime.SetTime(mockTime.Now().Add(1 * time.Minute)) + inst3.UpdateLastRequestTime() + + // Evict LRU instance (should be inst1) + if err := manager.EvictLRUInstance(); err != nil { + t.Fatalf("EvictLRUInstance failed: %v", err) + } + + // Verify inst1 is stopped + if inst1.IsRunning() { + t.Error("Expected instance-1 to be stopped after eviction") + } + + // Verify inst2 and inst3 are still running + if !inst2.IsRunning() { + t.Error("Expected instance-2 to still be running") + } + if !inst3.IsRunning() { + t.Error("Expected instance-3 to still be running") + } +} + +func TestEvictLRUInstance_NoRunningInstances(t *testing.T) { + manager := createTestManager() + defer manager.Shutdown() + + err := manager.EvictLRUInstance() + if err == nil { + t.Error("Expected error when no running instances exist") + } + if err.Error() != "failed to find lru instance" { + t.Errorf("Expected 'failed to find lru instance' error, got: %v", err) + } +} + +func TestEvictLRUInstance_OnlyEvictsTimeoutEnabledInstances(t *testing.T) { + manager := createTestManager() + defer manager.Shutdown() + + // Create mix of instances: some with timeout enabled, some disabled + // Only timeout-enabled instances should be eligible for eviction + validTimeout := 1 + zeroTimeout := 0 + instWithTimeout := createInstanceWithTimeout(t, manager, "with-timeout", "/path/to/model-with-timeout.gguf", &validTimeout) + instNoTimeout1 := createInstanceWithTimeout(t, manager, "no-timeout-1", "/path/to/model-no-timeout1.gguf", &zeroTimeout) + instNoTimeout2 := createInstanceWithTimeout(t, manager, "no-timeout-2", "/path/to/model-no-timeout2.gguf", nil) + + // Set all instances to running + instances := []*instance.Instance{instWithTimeout, instNoTimeout1, instNoTimeout2} + for _, inst := range instances { + inst.SetStatus(instance.Running) + inst.UpdateLastRequestTime() + } + defer func() { + // Reset instances to stopped to avoid shutdown panics + for _, inst := range instances { + if inst.IsRunning() { + inst.SetStatus(instance.Stopped) + } + } + }() + + // Evict LRU instance - should only consider the one with timeout + err := manager.EvictLRUInstance() + if err != nil { + t.Fatalf("EvictLRUInstance failed: %v", err) + } + + // Verify only the instance with timeout was evicted + if instWithTimeout.IsRunning() { + t.Error("Expected with-timeout instance to be stopped after eviction") + } + if !instNoTimeout1.IsRunning() { + t.Error("Expected no-timeout-1 instance to still be running") + } + if !instNoTimeout2.IsRunning() { + t.Error("Expected no-timeout-2 instance to still be running") + } +} + +// Helper function to create instances with different timeout configurations +func createInstanceWithTimeout(t *testing.T, manager manager.InstanceManager, name, model string, timeout *int) *instance.Instance { + t.Helper() + options := &instance.Options{ + IdleTimeout: timeout, + BackendOptions: backends.Options{ + BackendType: backends.BackendTypeLlamaCpp, + LlamaServerOptions: &backends.LlamaServerOptions{ + Model: model, + }, + }, + } + inst, err := manager.CreateInstance(name, options) + if err != nil { + t.Fatalf("CreateInstance failed: %v", err) + } + return inst +} + +// Helper for timeout tests +type MockTimeProvider struct { + currentTime time.Time + mu sync.RWMutex +} + +func NewMockTimeProvider(t time.Time) *MockTimeProvider { + return &MockTimeProvider{currentTime: t} +} + +func (m *MockTimeProvider) Now() time.Time { + m.mu.RLock() + defer m.mu.RUnlock() + return m.currentTime +} + +func (m *MockTimeProvider) SetTime(t time.Time) { + m.mu.Lock() + defer m.mu.Unlock() + m.currentTime = t +} diff --git a/pkg/manager/manager_test.go b/pkg/manager/manager_test.go index 997e0f6..b85c691 100644 --- a/pkg/manager/manager_test.go +++ b/pkg/manager/manager_test.go @@ -8,37 +8,16 @@ import ( "llamactl/pkg/manager" "os" "path/filepath" - "strings" "sync" "testing" ) func TestNewInstanceManager(t *testing.T) { - backendConfig := config.BackendConfig{ - LlamaCpp: config.BackendSettings{ - Command: "llama-server", - }, - MLX: config.BackendSettings{ - Command: "mlx_lm.server", - }, - } - - cfg := config.InstancesConfig{ - PortRange: [2]int{8000, 9000}, - LogsDir: "/tmp/test", - MaxInstances: 5, - DefaultAutoRestart: true, - DefaultMaxRestarts: 3, - DefaultRestartDelay: 5, - TimeoutCheckInterval: 5, - } - - mgr := manager.New(backendConfig, cfg, map[string]config.NodeConfig{}, "main") + mgr := createTestManager() if mgr == nil { t.Fatal("NewInstanceManager returned nil") } - // Test initial state instances, err := mgr.ListInstances() if err != nil { t.Fatalf("ListInstances failed: %v", err) @@ -48,26 +27,12 @@ func TestNewInstanceManager(t *testing.T) { } } -func TestPersistence(t *testing.T) { +func TestPersistence_SaveAndLoad(t *testing.T) { tempDir := t.TempDir() + cfg := createPersistenceConfig(tempDir) + backendConfig := createBackendConfig() - backendConfig := config.BackendConfig{ - LlamaCpp: config.BackendSettings{ - Command: "llama-server", - }, - MLX: config.BackendSettings{ - Command: "mlx_lm.server", - }, - } - - cfg := config.InstancesConfig{ - PortRange: [2]int{8000, 9000}, - InstancesDir: tempDir, - MaxInstances: 10, - TimeoutCheckInterval: 5, - } - - // Test instance persistence on creation + // Create instance and check file was created manager1 := manager.New(backendConfig, cfg, map[string]config.NodeConfig{}, "main") options := &instance.Options{ BackendOptions: backends.Options{ @@ -84,13 +49,12 @@ func TestPersistence(t *testing.T) { t.Fatalf("CreateInstance failed: %v", err) } - // Check that JSON file was created expectedPath := filepath.Join(tempDir, "test-instance.json") if _, err := os.Stat(expectedPath); os.IsNotExist(err) { t.Errorf("Expected persistence file %s to exist", expectedPath) } - // Test loading instances from disk + // Load instances from disk manager2 := manager.New(backendConfig, cfg, map[string]config.NodeConfig{}, "main") instances, err := manager2.ListInstances() if err != nil { @@ -102,15 +66,32 @@ func TestPersistence(t *testing.T) { if instances[0].Name != "test-instance" { t.Errorf("Expected loaded instance name 'test-instance', got %q", instances[0].Name) } +} - // Test port map populated from loaded instances (port conflict should be detected) - _, err = manager2.CreateInstance("new-instance", options) // Same port - if err == nil || !strings.Contains(err.Error(), "port") { - t.Errorf("Expected port conflict error, got: %v", err) +func TestPersistence_DeleteRemovesFile(t *testing.T) { + tempDir := t.TempDir() + cfg := createPersistenceConfig(tempDir) + backendConfig := createBackendConfig() + + mgr := manager.New(backendConfig, cfg, map[string]config.NodeConfig{}, "main") + options := &instance.Options{ + BackendOptions: backends.Options{ + BackendType: backends.BackendTypeLlamaCpp, + LlamaServerOptions: &backends.LlamaServerOptions{ + Model: "/path/to/model.gguf", + Port: 8080, + }, + }, } - // Test file deletion on instance deletion - err = manager2.DeleteInstance("test-instance") + _, err := mgr.CreateInstance("test-instance", options) + if err != nil { + t.Fatalf("CreateInstance failed: %v", err) + } + + expectedPath := filepath.Join(tempDir, "test-instance.json") + + err = mgr.DeleteInstance("test-instance") if err != nil { t.Fatalf("DeleteInstance failed: %v", err) } @@ -192,9 +173,9 @@ func TestShutdown(t *testing.T) { mgr.Shutdown() } -// Helper function to create a test manager with standard config -func createTestManager() manager.InstanceManager { - backendConfig := config.BackendConfig{ +// Helper functions for test configuration +func createBackendConfig() config.BackendConfig { + return config.BackendConfig{ LlamaCpp: config.BackendSettings{ Command: "llama-server", }, @@ -202,7 +183,18 @@ func createTestManager() manager.InstanceManager { Command: "mlx_lm.server", }, } +} +func createPersistenceConfig(dir string) config.InstancesConfig { + return config.InstancesConfig{ + PortRange: [2]int{8000, 9000}, + InstancesDir: dir, + MaxInstances: 10, + TimeoutCheckInterval: 5, + } +} + +func createTestManager() manager.InstanceManager { cfg := config.InstancesConfig{ PortRange: [2]int{8000, 9000}, LogsDir: "/tmp/test", @@ -212,24 +204,13 @@ func createTestManager() manager.InstanceManager { DefaultRestartDelay: 5, TimeoutCheckInterval: 5, } - return manager.New(backendConfig, cfg, map[string]config.NodeConfig{}, "main") + return manager.New(createBackendConfig(), cfg, map[string]config.NodeConfig{}, "main") } func TestAutoRestartDisabledInstanceStatus(t *testing.T) { tempDir := t.TempDir() - - backendConfig := config.BackendConfig{ - LlamaCpp: config.BackendSettings{ - Command: "llama-server", - }, - } - - cfg := config.InstancesConfig{ - PortRange: [2]int{8000, 9000}, - InstancesDir: tempDir, - MaxInstances: 10, - TimeoutCheckInterval: 5, - } + cfg := createPersistenceConfig(tempDir) + backendConfig := createBackendConfig() // Create first manager and instance with auto-restart disabled manager1 := manager.New(backendConfig, cfg, map[string]config.NodeConfig{}, "main") diff --git a/pkg/manager/operations_test.go b/pkg/manager/operations_test.go index 7523c5b..d078145 100644 --- a/pkg/manager/operations_test.go +++ b/pkg/manager/operations_test.go @@ -38,8 +38,7 @@ func TestCreateInstance_Success(t *testing.T) { } } -func TestCreateInstance_ValidationAndLimits(t *testing.T) { - // Test duplicate names +func TestCreateInstance_DuplicateName(t *testing.T) { mngr := createTestManager() options := &instance.Options{ BackendOptions: backends.Options{ @@ -63,15 +62,13 @@ func TestCreateInstance_ValidationAndLimits(t *testing.T) { if !strings.Contains(err.Error(), "already exists") { t.Errorf("Expected duplicate name error, got: %v", err) } +} - // Test max instances limit +func TestCreateInstance_MaxInstancesLimit(t *testing.T) { backendConfig := config.BackendConfig{ LlamaCpp: config.BackendSettings{ Command: "llama-server", }, - MLX: config.BackendSettings{ - Command: "mlx_lm.server", - }, } cfg := config.InstancesConfig{ PortRange: [2]int{8000, 9000}, @@ -80,7 +77,16 @@ func TestCreateInstance_ValidationAndLimits(t *testing.T) { } limitedManager := manager.New(backendConfig, cfg, map[string]config.NodeConfig{}, "main") - _, err = limitedManager.CreateInstance("instance1", options) + options := &instance.Options{ + BackendOptions: backends.Options{ + BackendType: backends.BackendTypeLlamaCpp, + LlamaServerOptions: &backends.LlamaServerOptions{ + Model: "/path/to/model.gguf", + }, + }, + } + + _, err := limitedManager.CreateInstance("instance1", options) if err != nil { t.Fatalf("CreateInstance 1 failed: %v", err) } @@ -95,78 +101,98 @@ func TestCreateInstance_ValidationAndLimits(t *testing.T) { } } -func TestPortManagement(t *testing.T) { - manager := createTestManager() - - // Test auto port assignment - options1 := &instance.Options{ - BackendOptions: backends.Options{ - BackendType: backends.BackendTypeLlamaCpp, - LlamaServerOptions: &backends.LlamaServerOptions{ - Model: "/path/to/model.gguf", - }, - }, - } - - inst1, err := manager.CreateInstance("instance1", options1) - if err != nil { - t.Fatalf("CreateInstance failed: %v", err) - } - - port1 := inst1.GetPort() - if port1 < 8000 || port1 > 9000 { - t.Errorf("Expected port in range 8000-9000, got %d", port1) - } - - // Test port conflict detection - options2 := &instance.Options{ - BackendOptions: backends.Options{ - BackendType: backends.BackendTypeLlamaCpp, - LlamaServerOptions: &backends.LlamaServerOptions{ - Model: "/path/to/model2.gguf", - Port: port1, // Same port - should conflict - }, - }, - } - - _, err = manager.CreateInstance("instance2", options2) - if err == nil { - t.Error("Expected error for port conflict") - } - if !strings.Contains(err.Error(), "port") && !strings.Contains(err.Error(), "in use") { - t.Errorf("Expected port conflict error, got: %v", err) - } - - // Test port release on deletion - specificPort := 8080 - options3 := &instance.Options{ - BackendOptions: backends.Options{ - BackendType: backends.BackendTypeLlamaCpp, - LlamaServerOptions: &backends.LlamaServerOptions{ - Model: "/path/to/model.gguf", - Port: specificPort, - }, - }, - } - - _, err = manager.CreateInstance("port-test", options3) - if err != nil { - t.Fatalf("CreateInstance failed: %v", err) - } - - err = manager.DeleteInstance("port-test") - if err != nil { - t.Fatalf("DeleteInstance failed: %v", err) - } - - // Should be able to create new instance with same port - _, err = manager.CreateInstance("new-port-test", options3) - if err != nil { - t.Errorf("Expected to reuse port after deletion, got error: %v", err) - } -} - -func TestInstanceOperations(t *testing.T) { +func TestPort_AutoAssignment(t *testing.T) { + manager := createTestManager() + + options := &instance.Options{ + BackendOptions: backends.Options{ + BackendType: backends.BackendTypeLlamaCpp, + LlamaServerOptions: &backends.LlamaServerOptions{ + Model: "/path/to/model.gguf", + }, + }, + } + + inst, err := manager.CreateInstance("instance1", options) + if err != nil { + t.Fatalf("CreateInstance failed: %v", err) + } + + port := inst.GetPort() + if port < 8000 || port > 9000 { + t.Errorf("Expected port in range 8000-9000, got %d", port) + } +} + +func TestPort_ConflictDetection(t *testing.T) { + manager := createTestManager() + + options1 := &instance.Options{ + BackendOptions: backends.Options{ + BackendType: backends.BackendTypeLlamaCpp, + LlamaServerOptions: &backends.LlamaServerOptions{ + Model: "/path/to/model.gguf", + Port: 8080, + }, + }, + } + + _, err := manager.CreateInstance("instance1", options1) + if err != nil { + t.Fatalf("CreateInstance failed: %v", err) + } + + // Try to create instance with same port + options2 := &instance.Options{ + BackendOptions: backends.Options{ + BackendType: backends.BackendTypeLlamaCpp, + LlamaServerOptions: &backends.LlamaServerOptions{ + Model: "/path/to/model2.gguf", + Port: 8080, // Same port - should conflict + }, + }, + } + + _, err = manager.CreateInstance("instance2", options2) + if err == nil { + t.Error("Expected error for port conflict") + } + if !strings.Contains(err.Error(), "port") && !strings.Contains(err.Error(), "in use") { + t.Errorf("Expected port conflict error, got: %v", err) + } +} + +func TestPort_ReleaseOnDeletion(t *testing.T) { + manager := createTestManager() + + options := &instance.Options{ + BackendOptions: backends.Options{ + BackendType: backends.BackendTypeLlamaCpp, + LlamaServerOptions: &backends.LlamaServerOptions{ + Model: "/path/to/model.gguf", + Port: 8080, + }, + }, + } + + _, err := manager.CreateInstance("port-test", options) + if err != nil { + t.Fatalf("CreateInstance failed: %v", err) + } + + err = manager.DeleteInstance("port-test") + if err != nil { + t.Fatalf("DeleteInstance failed: %v", err) + } + + // Should be able to create new instance with same port + _, err = manager.CreateInstance("new-port-test", options) + if err != nil { + t.Errorf("Expected to reuse port after deletion, got error: %v", err) + } +} + +func TestGetInstance(t *testing.T) { manager := createTestManager() options := &instance.Options{ @@ -178,13 +204,11 @@ func TestInstanceOperations(t *testing.T) { }, } - // Create instance created, err := manager.CreateInstance("test-instance", options) if err != nil { t.Fatalf("CreateInstance failed: %v", err) } - // Get instance retrieved, err := manager.GetInstance("test-instance") if err != nil { t.Fatalf("GetInstance failed: %v", err) @@ -192,8 +216,26 @@ func TestInstanceOperations(t *testing.T) { if retrieved.Name != created.Name { t.Errorf("Expected name %q, got %q", created.Name, retrieved.Name) } +} + +func TestUpdateInstance(t *testing.T) { + manager := createTestManager() + + options := &instance.Options{ + BackendOptions: backends.Options{ + BackendType: backends.BackendTypeLlamaCpp, + LlamaServerOptions: &backends.LlamaServerOptions{ + Model: "/path/to/model.gguf", + Port: 8080, + }, + }, + } + + _, err := manager.CreateInstance("test-instance", options) + if err != nil { + t.Fatalf("CreateInstance failed: %v", err) + } - // Update instance newOptions := &instance.Options{ BackendOptions: backends.Options{ BackendType: backends.BackendTypeLlamaCpp, @@ -211,8 +253,25 @@ func TestInstanceOperations(t *testing.T) { if updated.GetOptions().BackendOptions.LlamaServerOptions.Model != "/path/to/new-model.gguf" { t.Errorf("Expected model '/path/to/new-model.gguf', got %q", updated.GetOptions().BackendOptions.LlamaServerOptions.Model) } +} + +func TestListInstances(t *testing.T) { + manager := createTestManager() + + options := &instance.Options{ + BackendOptions: backends.Options{ + BackendType: backends.BackendTypeLlamaCpp, + LlamaServerOptions: &backends.LlamaServerOptions{ + Model: "/path/to/model.gguf", + }, + }, + } + + _, err := manager.CreateInstance("test-instance", options) + if err != nil { + t.Fatalf("CreateInstance failed: %v", err) + } - // List instances instances, err := manager.ListInstances() if err != nil { t.Fatalf("ListInstances failed: %v", err) @@ -220,8 +279,25 @@ func TestInstanceOperations(t *testing.T) { if len(instances) != 1 { t.Errorf("Expected 1 instance, got %d", len(instances)) } +} + +func TestDeleteInstance(t *testing.T) { + manager := createTestManager() + + options := &instance.Options{ + BackendOptions: backends.Options{ + BackendType: backends.BackendTypeLlamaCpp, + LlamaServerOptions: &backends.LlamaServerOptions{ + Model: "/path/to/model.gguf", + }, + }, + } + + _, err := manager.CreateInstance("test-instance", options) + if err != nil { + t.Fatalf("CreateInstance failed: %v", err) + } - // Delete instance err = manager.DeleteInstance("test-instance") if err != nil { t.Fatalf("DeleteInstance failed: %v", err) @@ -231,9 +307,21 @@ func TestInstanceOperations(t *testing.T) { if err == nil { t.Error("Instance should not exist after deletion") } +} - // Test operations on non-existent instances - _, err = manager.GetInstance("nonexistent") +func TestInstanceOperations_NonExistentInstance(t *testing.T) { + manager := createTestManager() + + options := &instance.Options{ + BackendOptions: backends.Options{ + BackendType: backends.BackendTypeLlamaCpp, + LlamaServerOptions: &backends.LlamaServerOptions{ + Model: "/path/to/model.gguf", + }, + }, + } + + _, err := manager.GetInstance("nonexistent") if err == nil || !strings.Contains(err.Error(), "not found") { t.Errorf("Expected 'not found' error, got: %v", err) } diff --git a/pkg/manager/timeout_test.go b/pkg/manager/timeout_test.go deleted file mode 100644 index e05c400..0000000 --- a/pkg/manager/timeout_test.go +++ /dev/null @@ -1,343 +0,0 @@ -package manager_test - -import ( - "llamactl/pkg/backends" - "llamactl/pkg/config" - "llamactl/pkg/instance" - "llamactl/pkg/manager" - "sync" - "testing" - "time" -) - -func TestTimeoutFunctionality(t *testing.T) { - // Test timeout checker initialization - backendConfig := config.BackendConfig{ - LlamaCpp: config.BackendSettings{Command: "llama-server"}, - MLX: config.BackendSettings{Command: "mlx_lm.server"}, - } - cfg := config.InstancesConfig{ - PortRange: [2]int{8000, 9000}, - TimeoutCheckInterval: 10, - MaxInstances: 5, - } - - manager := manager.New(backendConfig, cfg, map[string]config.NodeConfig{}, "main") - if manager == nil { - t.Fatal("Manager should be initialized with timeout checker") - } - manager.Shutdown() // Clean up - - // Test timeout configuration and logic without starting the actual process - testManager := createTestManager() - defer testManager.Shutdown() - - idleTimeout := 1 // 1 minute - options := &instance.Options{ - IdleTimeout: &idleTimeout, - BackendOptions: backends.Options{ - BackendType: backends.BackendTypeLlamaCpp, - LlamaServerOptions: &backends.LlamaServerOptions{ - Model: "/path/to/model.gguf", - }, - }, - } - - inst, err := testManager.CreateInstance("timeout-test", options) - if err != nil { - t.Fatalf("CreateInstance failed: %v", err) - } - - // Test timeout configuration is properly set - if inst.GetOptions().IdleTimeout == nil { - t.Fatal("Instance should have idle timeout configured") - } - if *inst.GetOptions().IdleTimeout != 1 { - t.Errorf("Expected idle timeout 1 minute, got %d", *inst.GetOptions().IdleTimeout) - } - - // Test timeout logic without actually starting the process - // Create a mock time provider to simulate timeout - mockTime := NewMockTimeProvider(time.Now()) - inst.SetTimeProvider(mockTime) - - // Set instance to running state so timeout logic can work - inst.SetStatus(instance.Running) - - // Simulate instance being "running" for timeout check (without actual process) - // We'll test the ShouldTimeout logic directly - inst.UpdateLastRequestTime() - - // Initially should not timeout (just updated) - if inst.ShouldTimeout() { - t.Error("Instance should not timeout immediately after request") - } - - // Advance time to trigger timeout - mockTime.SetTime(time.Now().Add(2 * time.Minute)) - - // Now it should timeout - if !inst.ShouldTimeout() { - t.Error("Instance should timeout after idle period") - } - - // Reset running state to avoid shutdown issues - inst.SetStatus(instance.Stopped) - - // Test that instance without timeout doesn't timeout - noTimeoutOptions := &instance.Options{ - BackendOptions: backends.Options{ - BackendType: backends.BackendTypeLlamaCpp, - LlamaServerOptions: &backends.LlamaServerOptions{ - Model: "/path/to/model.gguf", - }, - }, - // No IdleTimeout set - } - - noTimeoutInst, err := testManager.CreateInstance("no-timeout-test", noTimeoutOptions) - if err != nil { - t.Fatalf("CreateInstance failed: %v", err) - } - - noTimeoutInst.SetTimeProvider(mockTime) - noTimeoutInst.SetStatus(instance.Running) // Set to running for timeout check - noTimeoutInst.UpdateLastRequestTime() - - // Even with time advanced, should not timeout - if noTimeoutInst.ShouldTimeout() { - t.Error("Instance without timeout configuration should never timeout") - } - - // Reset running state to avoid shutdown issues - noTimeoutInst.SetStatus(instance.Stopped) -} - -func TestEvictLRUInstance_Success(t *testing.T) { - manager := createTestManager() - // Don't defer manager.Shutdown() - we'll handle cleanup manually - - // Create 3 instances with idle timeout enabled (value doesn't matter for LRU logic) - options1 := &instance.Options{ - IdleTimeout: func() *int { timeout := 1; return &timeout }(), // Any value > 0 - BackendOptions: backends.Options{ - BackendType: backends.BackendTypeLlamaCpp, - LlamaServerOptions: &backends.LlamaServerOptions{ - Model: "/path/to/model1.gguf", - }, - }, - } - options2 := &instance.Options{ - IdleTimeout: func() *int { timeout := 1; return &timeout }(), // Any value > 0 - BackendOptions: backends.Options{ - BackendType: backends.BackendTypeLlamaCpp, - LlamaServerOptions: &backends.LlamaServerOptions{ - Model: "/path/to/model2.gguf", - }, - }, - } - options3 := &instance.Options{ - IdleTimeout: func() *int { timeout := 1; return &timeout }(), // Any value > 0 - BackendOptions: backends.Options{ - BackendType: backends.BackendTypeLlamaCpp, - LlamaServerOptions: &backends.LlamaServerOptions{ - Model: "/path/to/model3.gguf", - }, - }, - } - - inst1, err := manager.CreateInstance("instance-1", options1) - if err != nil { - t.Fatalf("CreateInstance failed: %v", err) - } - inst2, err := manager.CreateInstance("instance-2", options2) - if err != nil { - t.Fatalf("CreateInstance failed: %v", err) - } - inst3, err := manager.CreateInstance("instance-3", options3) - if err != nil { - t.Fatalf("CreateInstance failed: %v", err) - } - - // Set up mock time and set instances to running - mockTime := NewMockTimeProvider(time.Now()) - inst1.SetTimeProvider(mockTime) - inst2.SetTimeProvider(mockTime) - inst3.SetTimeProvider(mockTime) - - inst1.SetStatus(instance.Running) - inst2.SetStatus(instance.Running) - inst3.SetStatus(instance.Running) - - // Set different last request times (oldest to newest) - // inst1: oldest (will be evicted) - inst1.UpdateLastRequestTime() - - mockTime.SetTime(mockTime.Now().Add(1 * time.Minute)) - inst2.UpdateLastRequestTime() - - mockTime.SetTime(mockTime.Now().Add(1 * time.Minute)) - inst3.UpdateLastRequestTime() - - // Evict LRU instance (should be inst1) - err = manager.EvictLRUInstance() - if err != nil { - t.Fatalf("EvictLRUInstance failed: %v", err) - } - - // Verify inst1 is stopped - if inst1.IsRunning() { - t.Error("Expected instance-1 to be stopped after eviction") - } - - // Verify inst2 and inst3 are still running - if !inst2.IsRunning() { - t.Error("Expected instance-2 to still be running") - } - if !inst3.IsRunning() { - t.Error("Expected instance-3 to still be running") - } - - // Clean up manually - set all to stopped and then shutdown - inst2.SetStatus(instance.Stopped) - inst3.SetStatus(instance.Stopped) -} - -func TestEvictLRUInstance_NoEligibleInstances(t *testing.T) { - // Helper function to create instances with different timeout configurations - createInstanceWithTimeout := func(manager manager.InstanceManager, name, model string, timeout *int) *instance.Instance { - options := &instance.Options{ - IdleTimeout: timeout, - BackendOptions: backends.Options{ - BackendType: backends.BackendTypeLlamaCpp, - LlamaServerOptions: &backends.LlamaServerOptions{ - Model: model, - }, - }, - } - inst, err := manager.CreateInstance(name, options) - if err != nil { - t.Fatalf("CreateInstance failed: %v", err) - } - return inst - } - - t.Run("no running instances", func(t *testing.T) { - manager := createTestManager() - defer manager.Shutdown() - - err := manager.EvictLRUInstance() - if err == nil { - t.Error("Expected error when no running instances exist") - } - if err.Error() != "failed to find lru instance" { - t.Errorf("Expected 'failed to find lru instance' error, got: %v", err) - } - }) - - t.Run("only instances without timeout", func(t *testing.T) { - manager := createTestManager() - defer manager.Shutdown() - - // Create instances with various non-eligible timeout configurations - zeroTimeout := 0 - negativeTimeout := -1 - inst1 := createInstanceWithTimeout(manager, "no-timeout-1", "/path/to/model1.gguf", &zeroTimeout) - inst2 := createInstanceWithTimeout(manager, "no-timeout-2", "/path/to/model2.gguf", &negativeTimeout) - inst3 := createInstanceWithTimeout(manager, "no-timeout-3", "/path/to/model3.gguf", nil) - - // Set instances to running - instances := []*instance.Instance{inst1, inst2, inst3} - for _, inst := range instances { - inst.SetStatus(instance.Running) - } - defer func() { - // Reset instances to stopped to avoid shutdown panics - for _, inst := range instances { - inst.SetStatus(instance.Stopped) - } - }() - - // Try to evict - should fail because no eligible instances - err := manager.EvictLRUInstance() - if err == nil { - t.Error("Expected error when no eligible instances exist") - } - if err.Error() != "failed to find lru instance" { - t.Errorf("Expected 'failed to find lru instance' error, got: %v", err) - } - - // Verify all instances are still running - for i, inst := range instances { - if !inst.IsRunning() { - t.Errorf("Expected instance %d to still be running", i+1) - } - } - }) - - t.Run("mixed instances - evicts only eligible ones", func(t *testing.T) { - manager := createTestManager() - defer manager.Shutdown() - - // Create mix of instances: some with timeout enabled, some disabled - validTimeout := 1 - zeroTimeout := 0 - instWithTimeout := createInstanceWithTimeout(manager, "with-timeout", "/path/to/model-with-timeout.gguf", &validTimeout) - instNoTimeout1 := createInstanceWithTimeout(manager, "no-timeout-1", "/path/to/model-no-timeout1.gguf", &zeroTimeout) - instNoTimeout2 := createInstanceWithTimeout(manager, "no-timeout-2", "/path/to/model-no-timeout2.gguf", nil) - - // Set all instances to running - instances := []*instance.Instance{instWithTimeout, instNoTimeout1, instNoTimeout2} - for _, inst := range instances { - inst.SetStatus(instance.Running) - inst.UpdateLastRequestTime() - } - defer func() { - // Reset instances to stopped to avoid shutdown panics - for _, inst := range instances { - if inst.IsRunning() { - inst.SetStatus(instance.Stopped) - } - } - }() - - // Evict LRU instance - should only consider the one with timeout - err := manager.EvictLRUInstance() - if err != nil { - t.Fatalf("EvictLRUInstance failed: %v", err) - } - - // Verify only the instance with timeout was evicted - if instWithTimeout.IsRunning() { - t.Error("Expected with-timeout instance to be stopped after eviction") - } - if !instNoTimeout1.IsRunning() { - t.Error("Expected no-timeout-1 instance to still be running") - } - if !instNoTimeout2.IsRunning() { - t.Error("Expected no-timeout-2 instance to still be running") - } - }) -} - -// Helper for timeout tests -type MockTimeProvider struct { - currentTime time.Time - mu sync.RWMutex -} - -func NewMockTimeProvider(t time.Time) *MockTimeProvider { - return &MockTimeProvider{currentTime: t} -} - -func (m *MockTimeProvider) Now() time.Time { - m.mu.RLock() - defer m.mu.RUnlock() - return m.currentTime -} - -func (m *MockTimeProvider) SetTime(t time.Time) { - m.mu.Lock() - defer m.mu.Unlock() - m.currentTime = t -}