import { EventEmitter } from 'node:events'; import path from 'node:path'; import type { ChildProcess } from 'node:child_process'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const processMocks = vi.hoisted(() => ({ existsSync: vi.fn(() => false), openSync: vi.fn(() => 19), spawnSidecarOwnedProcess: vi.fn(), spawnSync: vi.fn(), })); vi.mock('@waggle/agent', () => ({ spawnSidecarOwnedProcess: processMocks.spawnSidecarOwnedProcess, })); vi.mock('node:child_process', async (importOriginal) => ({ ...(await importOriginal()), spawnSync: processMocks.spawnSync, })); vi.mock('node:fs', async (importOriginal) => ({ ...(await importOriginal()), existsSync: processMocks.existsSync, openSync: processMocks.openSync, })); import { startLiteLLM, stopLiteLLM } from '../../src/local/lifecycle.js'; const TEST_CONFIG_PATH = path.resolve('waggle-test', 'litellm.runtime.json'); const TEST_CONFIG_DIR = path.dirname(TEST_CONFIG_PATH); function exitedChild(): ChildProcess { const child = new EventEmitter() as ChildProcess; Object.defineProperty(child, 'exitCode', { configurable: true, value: 1, writable: true, }); Object.defineProperty(child, 'signalCode', { configurable: true, value: null }); child.kill = vi.fn(() => true); return child; } function runningChild(exitCodeOnShutdown = 0): ChildProcess { const child = new EventEmitter() as ChildProcess; Object.defineProperty(child, 'connected', { configurable: true, value: true }); Object.defineProperty(child, 'exitCode', { configurable: true, value: null, writable: true, }); Object.defineProperty(child, 'signalCode', { configurable: true, value: null }); child.kill = vi.fn(() => true); child.send = vi.fn((_message, callback) => { callback?.(null); queueMicrotask(() => { Object.defineProperty(child, 'exitCode', { configurable: true, value: exitCodeOnShutdown, writable: true, }); child.emit('exit', exitCodeOnShutdown, null); }); return true; }) as ChildProcess['send']; return child; } describe('managed LiteLLM process ownership', () => { beforeEach(() => { vi.useFakeTimers(); vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('offline'); })); processMocks.spawnSync.mockImplementation((_command, args) => { if (Array.isArray(args) && args[0] === '-c') { return { error: undefined, status: 0 }; } return { error: undefined, status: 0, stdout: 'C:\\Python\\python.exe\r\n', }; }); processMocks.spawnSidecarOwnedProcess.mockReturnValue(exitedChild()); }); afterEach(async () => { await stopLiteLLM(); vi.unstubAllGlobals(); vi.useRealTimers(); vi.clearAllMocks(); }); it('starts LiteLLM through the sidecar-owned process boundary', async () => { const child = exitedChild(); processMocks.spawnSidecarOwnedProcess.mockReturnValue(child); const start = startLiteLLM(43111, TEST_CONFIG_PATH); await Promise.resolve(); await Promise.resolve(); await vi.advanceTimersByTimeAsync(1_000); await expect(start).resolves.toMatchObject({ error: 'LiteLLM exited with code 1', port: 43111, status: 'error', }); expect(processMocks.spawnSidecarOwnedProcess).toHaveBeenCalledWith( 'C:\\Python\\python.exe', [ '-m', 'litellm.proxy.proxy_cli', '--config', TEST_CONFIG_PATH, '--port', '43111', ], expect.objectContaining({ cwd: TEST_CONFIG_DIR, windowsHide: true, }), ); await stopLiteLLM(); }); it('stops LiteLLM through supervisor IPC and waits for confirmed cleanup', async () => { const child = runningChild(); processMocks.spawnSidecarOwnedProcess.mockReturnValue(child); vi.mocked(fetch) .mockRejectedValueOnce(new TypeError('offline')) .mockResolvedValueOnce({ ok: true } as Response); const start = startLiteLLM(43112, TEST_CONFIG_PATH); await Promise.resolve(); await Promise.resolve(); await vi.advanceTimersByTimeAsync(1_000); await expect(start).resolves.toMatchObject({ status: 'started' }); await stopLiteLLM(); expect(child.send).toHaveBeenCalledWith('shutdown', expect.any(Function)); expect(child.kill).not.toHaveBeenCalled(); }); it('retains a failed supervisor handle when process-tree cleanup is unconfirmed', async () => { const child = runningChild(1); processMocks.spawnSidecarOwnedProcess.mockReturnValue(child); vi.mocked(fetch) .mockRejectedValueOnce(new TypeError('offline')) .mockResolvedValueOnce({ ok: true } as Response); const start = startLiteLLM(43113, TEST_CONFIG_PATH); await Promise.resolve(); await Promise.resolve(); await vi.advanceTimersByTimeAsync(1_000); await expect(start).resolves.toMatchObject({ status: 'started' }); await expect(stopLiteLLM()).rejects.toThrow( 'Sidecar-owned LiteLLM supervisor did not confirm cleanup (code=1, signal=null)', ); expect(child.kill).not.toHaveBeenCalled(); vi.mocked(fetch).mockResolvedValue({ ok: true } as Response); await expect(startLiteLLM(43113)).resolves.toMatchObject({ error: 'Previous LiteLLM process-tree cleanup is not confirmed', status: 'error', }); expect(processMocks.spawnSidecarOwnedProcess).toHaveBeenCalledTimes(1); Object.defineProperty(child, 'exitCode', { configurable: true, value: 0, writable: true, }); await stopLiteLLM(); }); });