76d5fedfb5
큰 입력 시 "Failed to acquire LM Studio model handle … Operation canceled" 로 턴 전체가 죽던 문제를 3계층으로 해결. 일반 채팅(코어 경로)은 그동안 단일 예산 호출이라 약한 모델·큰 입력에서 무너졌다 — 그 갭을 메움. - 핸들 race 수정: getModelHandle 을 재시도 루프 안으로 이동. 취소/죽은-핸들 류 에러는 SDK 재생성 후 1회 자동 재시도(실제 사용자 취소는 존중). 라이프 사이클의 동시 로드가 abort 되며 SDK 가 coalesce 한 JIT 조회까지 죽던 것. - Phase 1 실제 창 정렬: llm.getContextLength()(캐시)로 실측 창에 예산 클램프. 설정값보다 작은 창으로 로드된 경우 서버 truncation/빈 답변 차단. 배지에 표시. - Phase 2 코어 Map-Reduce: 단일 입력이 (유효 창 × ratio) 초과 시 청크→질의 인지형 추출→통합. 부분/전체 폴백, 무관 시 정직 신호. 동시성 기본 2. - Phase 3 메타 노출: 진행/결과 배지 표시, [조각 k] 출처 옵트인. 신규 설정 5종. /meet·/review 전용 경로는 불변. 테스트 +25건, 전체 684 통과. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
/**
|
|
* Unit tests for ModelLifecycleManager.
|
|
*
|
|
* Strategy: inject mock ILMStudioClient and a simple in-memory IActivityTracker.
|
|
* No real LM Studio or SDK is touched — the manager file does not import the
|
|
* SDK directly (only types via `import type`).
|
|
*/
|
|
|
|
import {
|
|
ModelLifecycleManager,
|
|
LifecycleConfig,
|
|
LifecycleManagerDeps,
|
|
} from '../src/lmstudio/lifecycleManager';
|
|
import type { ILMStudioClient } from '../src/lmstudio/client';
|
|
import type { IActivityTracker } from '../src/lmstudio/activityTracker';
|
|
|
|
class FakeActivityTracker implements IActivityTracker {
|
|
private listeners: Array<(_: void) => any> = [];
|
|
public readonly onActivity = ((listener: (_: void) => any) => {
|
|
this.listeners.push(listener);
|
|
return { dispose: () => { this.listeners = this.listeners.filter(l => l !== listener); } };
|
|
}) as any;
|
|
bump(): void {
|
|
for (const l of this.listeners.slice()) l();
|
|
}
|
|
}
|
|
|
|
class FakeLMStudioClient implements ILMStudioClient {
|
|
public loadCalls: string[] = [];
|
|
public unloadCalls: string[] = [];
|
|
public listLoadedCalls = 0;
|
|
public failNextLoad: Error | null = null;
|
|
public failNextUnload: Error | null = null;
|
|
public loadDelayMs = 0;
|
|
public lastLoadSignal: AbortSignal | undefined;
|
|
/** 실제 로드 상태 추적 — listLoaded()가 이를 반영해야 lifecycle 의 '전체 언로드'를 검증할 수 있다. */
|
|
public loaded = new Set<string>();
|
|
|
|
setBaseUrl(_: string): void { /* noop */ }
|
|
|
|
async load(modelKey: string, signal?: AbortSignal): Promise<void> {
|
|
this.loadCalls.push(modelKey);
|
|
this.lastLoadSignal = signal;
|
|
if (this.loadDelayMs > 0) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const t = setTimeout(resolve, this.loadDelayMs);
|
|
if (signal) {
|
|
const onAbort = () => { clearTimeout(t); reject(new Error('aborted')); };
|
|
if (signal.aborted) onAbort();
|
|
else signal.addEventListener('abort', onAbort);
|
|
}
|
|
});
|
|
}
|
|
if (this.failNextLoad) {
|
|
const err = this.failNextLoad;
|
|
this.failNextLoad = null;
|
|
throw err;
|
|
}
|
|
this.loaded.add(modelKey); // 성공 시에만 로드 상태로
|
|
}
|
|
|
|
async unload(modelKey: string): Promise<void> {
|
|
this.unloadCalls.push(modelKey);
|
|
if (this.failNextUnload) {
|
|
const err = this.failNextUnload;
|
|
this.failNextUnload = null;
|
|
throw err; // 실패 시 로드 상태 유지
|
|
}
|
|
this.loaded.delete(modelKey);
|
|
}
|
|
|
|
async listLoaded(): Promise<string[]> {
|
|
this.listLoadedCalls++;
|
|
return [...this.loaded];
|
|
}
|
|
|
|
async isReachable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
|
|
async getModelContextLength(_modelKey: string): Promise<number | undefined> {
|
|
return undefined;
|
|
}
|
|
|
|
async listLoadedCached(): Promise<string[]> {
|
|
return [...this.loaded];
|
|
}
|
|
|
|
async listDownloaded(): Promise<string[]> {
|
|
return [];
|
|
}
|
|
|
|
async listDownloadedCached(): Promise<string[]> {
|
|
return [];
|
|
}
|
|
|
|
async getModelHandle(_modelKey: string): Promise<any> {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function makeManager(overrides: Partial<LifecycleConfig> = {}, depOverrides: Partial<LifecycleManagerDeps> = {}) {
|
|
const client = new FakeLMStudioClient();
|
|
const activity = new FakeActivityTracker();
|
|
const config: LifecycleConfig = { idleTimeoutMs: 1000, autoLoadOnSelect: true, ...overrides };
|
|
const errors: string[] = [];
|
|
const manager = new ModelLifecycleManager({
|
|
client,
|
|
activity,
|
|
getConfig: () => config,
|
|
notifyError: (m) => errors.push(m),
|
|
switchDebounceMs: 0,
|
|
initialEngine: 'lmstudio',
|
|
...depOverrides,
|
|
});
|
|
return { manager, client, activity, config, errors };
|
|
}
|
|
|
|
const flush = () => new Promise<void>((r) => setImmediate(r));
|
|
|
|
describe('ModelLifecycleManager', () => {
|
|
beforeEach(() => {
|
|
jest.useFakeTimers({ doNotFake: ['setImmediate'] });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
test('loads model on selection and arms idle timer', async () => {
|
|
const { manager, client } = makeManager();
|
|
manager.onModelSelected('llama-3.2-3b');
|
|
await flush();
|
|
expect(client.loadCalls).toEqual(['llama-3.2-3b']);
|
|
expect(manager._getState()).toBe('loaded');
|
|
expect(manager._getCurrentModel()).toBe('llama-3.2-3b');
|
|
expect(manager._hasIdleTimer()).toBe(true);
|
|
});
|
|
|
|
test('idle timer triggers unload after timeout', async () => {
|
|
const { manager, client } = makeManager({ idleTimeoutMs: 1000 });
|
|
manager.onModelSelected('m1');
|
|
await flush();
|
|
expect(manager._getState()).toBe('loaded');
|
|
|
|
jest.advanceTimersByTime(1000);
|
|
await flush();
|
|
expect(client.unloadCalls).toEqual(['m1']);
|
|
expect(manager._getState()).toBe('idle');
|
|
expect(manager._getCurrentModel()).toBe(null);
|
|
});
|
|
|
|
test('idleTimeoutMs <= 0 disables auto-eject', async () => {
|
|
const { manager, client } = makeManager({ idleTimeoutMs: 0 });
|
|
manager.onModelSelected('m1');
|
|
await flush();
|
|
expect(manager._getState()).toBe('loaded');
|
|
expect(manager._hasIdleTimer()).toBe(false);
|
|
|
|
jest.advanceTimersByTime(1_000_000);
|
|
await flush();
|
|
expect(client.unloadCalls).toEqual([]);
|
|
expect(manager._getState()).toBe('loaded');
|
|
});
|
|
|
|
test('activity bump resets idle timer', async () => {
|
|
const { manager, client, activity } = makeManager({ idleTimeoutMs: 1000 });
|
|
manager.onModelSelected('m1');
|
|
await flush();
|
|
|
|
jest.advanceTimersByTime(900);
|
|
activity.bump();
|
|
jest.advanceTimersByTime(900); // total 1800ms but timer reset at 900
|
|
await flush();
|
|
expect(client.unloadCalls).toEqual([]);
|
|
expect(manager._getState()).toBe('loaded');
|
|
|
|
jest.advanceTimersByTime(200); // 200 + 900 since reset = ~1100 since reset
|
|
await flush();
|
|
expect(client.unloadCalls).toEqual(['m1']);
|
|
});
|
|
|
|
test('streamStart pauses idle timer; streamEnd resumes', async () => {
|
|
const { manager, client } = makeManager({ idleTimeoutMs: 500 });
|
|
manager.onModelSelected('m1');
|
|
await flush();
|
|
expect(manager._hasIdleTimer()).toBe(true);
|
|
|
|
manager.onStreamStart();
|
|
expect(manager._getState()).toBe('streaming');
|
|
expect(manager._hasIdleTimer()).toBe(false);
|
|
|
|
jest.advanceTimersByTime(10000);
|
|
await flush();
|
|
expect(client.unloadCalls).toEqual([]); // never ejected during stream
|
|
|
|
manager.onStreamEnd();
|
|
expect(manager._getState()).toBe('loaded');
|
|
expect(manager._hasIdleTimer()).toBe(true);
|
|
|
|
jest.advanceTimersByTime(500);
|
|
await flush();
|
|
expect(client.unloadCalls).toEqual(['m1']);
|
|
});
|
|
|
|
test('model switch unloads previous and loads next', async () => {
|
|
const { manager, client } = makeManager();
|
|
manager.onModelSelected('m1');
|
|
await flush();
|
|
|
|
manager.onModelSelected('m2');
|
|
await flush();
|
|
expect(client.unloadCalls).toEqual(['m1']);
|
|
expect(client.loadCalls).toEqual(['m1', 'm2']);
|
|
expect(manager._getCurrentModel()).toBe('m2');
|
|
expect(manager._getState()).toBe('loaded');
|
|
});
|
|
|
|
test('switch during streaming defers until streamEnd', async () => {
|
|
const { manager, client } = makeManager();
|
|
manager.onModelSelected('m1');
|
|
await flush();
|
|
manager.onStreamStart();
|
|
|
|
manager.onModelSelected('m2');
|
|
await flush();
|
|
// No switch yet — still in streaming with m1
|
|
expect(client.unloadCalls).toEqual([]);
|
|
expect(client.loadCalls).toEqual(['m1']);
|
|
|
|
manager.onStreamEnd();
|
|
await flush();
|
|
expect(client.unloadCalls).toEqual(['m1']);
|
|
expect(client.loadCalls).toEqual(['m1', 'm2']);
|
|
expect(manager._getCurrentModel()).toBe('m2');
|
|
});
|
|
|
|
test('load failure surfaces via notifyError and resets to idle', async () => {
|
|
const { manager, client, errors } = makeManager();
|
|
client.failNextLoad = new Error('LM Studio is not running');
|
|
manager.onModelSelected('m1');
|
|
await flush();
|
|
expect(manager._getState()).toBe('idle');
|
|
expect(manager._getCurrentModel()).toBe(null);
|
|
expect(errors.length).toBe(1);
|
|
expect(errors[0]).toContain('LM Studio is not running');
|
|
});
|
|
|
|
test('engine change to non-lmstudio clears state without unloading', async () => {
|
|
const { manager, client } = makeManager();
|
|
manager.onModelSelected('m1');
|
|
await flush();
|
|
expect(manager._getState()).toBe('loaded');
|
|
|
|
manager.setEngine('ollama');
|
|
expect(manager._getState()).toBe('idle');
|
|
expect(manager._getCurrentModel()).toBe(null);
|
|
expect(manager._hasIdleTimer()).toBe(false);
|
|
expect(client.unloadCalls).toEqual([]); // explicitly does not call unload
|
|
});
|
|
|
|
test('selection while engine is non-lmstudio is a no-op', async () => {
|
|
const { manager, client } = makeManager({}, { initialEngine: 'ollama' });
|
|
manager.onModelSelected('m1');
|
|
await flush();
|
|
expect(client.loadCalls).toEqual([]);
|
|
expect(manager._getState()).toBe('idle');
|
|
});
|
|
|
|
test('autoLoadOnSelect=false skips loading', async () => {
|
|
const { manager, client } = makeManager({ autoLoadOnSelect: false });
|
|
manager.onModelSelected('m1');
|
|
await flush();
|
|
expect(client.loadCalls).toEqual([]);
|
|
expect(manager._getState()).toBe('idle');
|
|
});
|
|
|
|
test('rapid switch debounce: only the last selection wins', async () => {
|
|
// Re-create manager with non-zero debounce
|
|
const client = new FakeLMStudioClient();
|
|
const activity = new FakeActivityTracker();
|
|
const config: LifecycleConfig = { idleTimeoutMs: 1000, autoLoadOnSelect: true };
|
|
const manager = new ModelLifecycleManager({
|
|
client,
|
|
activity,
|
|
getConfig: () => config,
|
|
switchDebounceMs: 300,
|
|
initialEngine: 'lmstudio',
|
|
});
|
|
manager.onModelSelected('m1');
|
|
manager.onModelSelected('m2');
|
|
manager.onModelSelected('m3');
|
|
// Before debounce expires no load fires
|
|
expect(client.loadCalls).toEqual([]);
|
|
jest.advanceTimersByTime(300);
|
|
await flush();
|
|
expect(client.loadCalls).toEqual(['m3']);
|
|
});
|
|
|
|
test('disposeAndUnload ejects loaded model', async () => {
|
|
const { manager, client } = makeManager();
|
|
manager.onModelSelected('m1');
|
|
await flush();
|
|
await manager.disposeAndUnload(2000);
|
|
expect(client.unloadCalls).toEqual(['m1']);
|
|
expect(manager._getState()).toBe('idle');
|
|
});
|
|
|
|
test('disposeAndUnload while idle is a no-op', async () => {
|
|
const { manager, client } = makeManager();
|
|
await manager.disposeAndUnload(500);
|
|
expect(client.unloadCalls).toEqual([]);
|
|
});
|
|
|
|
test('selecting same model does not re-load but refreshes timer', async () => {
|
|
const { manager, client } = makeManager({ idleTimeoutMs: 1000 });
|
|
manager.onModelSelected('m1');
|
|
await flush();
|
|
expect(client.loadCalls.length).toBe(1);
|
|
|
|
jest.advanceTimersByTime(800);
|
|
manager.onModelSelected('m1');
|
|
await flush();
|
|
expect(client.loadCalls.length).toBe(1); // no reload
|
|
// Timer was reset; should survive past original 1000ms
|
|
jest.advanceTimersByTime(700);
|
|
await flush();
|
|
expect(client.unloadCalls).toEqual([]);
|
|
|
|
jest.advanceTimersByTime(400);
|
|
await flush();
|
|
expect(client.unloadCalls).toEqual(['m1']);
|
|
});
|
|
});
|