fix(ollama): stop pull spinner on failure and only activate pulled models

This commit is contained in:
Yun Chan 2026-09-28 02:16:25 +09:00
parent ed8b585804
commit 6533084a0d
5 changed files with 613 additions and 68 deletions

View file

@ -0,0 +1,278 @@
// tests/unit/ollama-model-pull-redteam-r2-34.test.ts
// Ollama 모델 받기: 실패 시 영구 '다운로드 중' + 받지 못한 모델 활성화 회귀 테스트.
// node 환경에서 가짜 포트(electronAPI 대역)로 성공·실패·진행 이벤트를 검증한다.
import { describe, it, expect, vi } from 'vitest'
import { ipcError, ipcSuccess, ErrorCode, type IPCResult } from '@d3ro/core/errors'
import {
EMPTY_PULL_PROGRESS,
OllamaModelPullController,
type OllamaModelPullPort,
type OllamaPullProgressEvent,
} from '../../src/renderer/services/ollamaModelPullController'
interface Deferred<T> {
promise: Promise<T>
resolve: (value: T) => void
reject: (err: Error) => void
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (err: Error) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
interface FakePort extends OllamaModelPullPort {
emit: (e: Partial<OllamaPullProgressEvent> & { modelId: string }) => void
pullResult: Deferred<IPCResult<void>>
pullCalls: string[]
setModelCalls: string[]
persistCalls: string[]
listenerCount: () => number
}
function makePort(overrides: Partial<OllamaModelPullPort> = {}): FakePort {
const listeners = new Set<(e: OllamaPullProgressEvent) => void>()
const port: FakePort = {
pullResult: deferred<IPCResult<void>>(),
pullCalls: [],
setModelCalls: [],
persistCalls: [],
pullModel: (modelId) => {
port.pullCalls.push(modelId)
return port.pullResult.promise
},
setModel: async (modelId) => {
port.setModelCalls.push(modelId)
return ipcSuccess(undefined)
},
persistModelId: async (modelId) => {
port.persistCalls.push(modelId)
return ipcSuccess(undefined)
},
onPullProgress: (cb) => {
listeners.add(cb)
return () => {
listeners.delete(cb)
}
},
emit: (e) => {
const full: OllamaPullProgressEvent = {
status: 'pulling',
digest: 'sha256:x',
total: 100,
completed: 0,
percent: 0,
...e,
}
for (const cb of listeners) cb(full)
},
listenerCount: () => listeners.size,
...overrides,
}
return port
}
/** 즉시 실행 스케줄러 — 표시 지연을 건너뛴다 */
const immediate = (fn: () => void): void => fn()
describe('OllamaModelPullController — pull 실패 처리', () => {
it('main이 {success:false}로 resolve하면 진행 상태를 풀고 오류를 남긴다 (영구 스피너 방지)', async () => {
const port = makePort()
const c = new OllamaModelPullController(port, { schedule: immediate })
const detach = c.attach()
const p = c.pull('gemma4:e4b')
expect(c.getSnapshot().pullingModelId).toBe('gemma4:e4b')
port.emit({ modelId: 'gemma4:e4b', percent: 42, completed: 42 })
expect(c.getSnapshot().progress.percent).toBe(42)
port.pullResult.resolve(ipcError(ErrorCode.LLMServerUnreachable, 'Pull failed: HTTP 500'))
await expect(p).resolves.toBe('failed')
const snap = c.getSnapshot()
expect(snap.pullingModelId).toBeNull()
expect(snap.progress).toEqual(EMPTY_PULL_PROGRESS)
expect(snap.error).toEqual({ modelId: 'gemma4:e4b', stage: 'pull', message: 'Pull failed: HTTP 500' })
detach()
})
it('autoSelect여도 받기에 실패하면 setModel/llmModelId 저장을 하지 않는다', async () => {
const port = makePort()
const c = new OllamaModelPullController(port, { autoSelect: true, schedule: immediate })
const p = c.pull('gemma4:e4b')
port.pullResult.resolve(ipcError(ErrorCode.LLMServerUnreachable, 'Pull failed: disk full'))
await expect(p).resolves.toBe('failed')
expect(port.setModelCalls).toEqual([])
expect(port.persistCalls).toEqual([])
})
it('IPC 호출이 reject되어도 실패로 처리하고 진행 상태를 푼다', async () => {
const port = makePort()
const c = new OllamaModelPullController(port, { autoSelect: true, schedule: immediate })
const p = c.pull('llama3.2:3b')
port.pullResult.reject(new Error('ipc gone'))
await expect(p).resolves.toBe('failed')
expect(c.getSnapshot().pullingModelId).toBeNull()
expect(c.getSnapshot().error?.message).toBe('ipc gone')
expect(port.setModelCalls).toEqual([])
})
it('실패 후 다시 받기를 시작할 수 있고, 새 시도는 이전 오류를 지운다', async () => {
const port = makePort()
const c = new OllamaModelPullController(port, { schedule: immediate })
const first = c.pull('phi4')
port.pullResult.resolve(ipcError(ErrorCode.LLMServerUnreachable, 'boom'))
await first
port.pullResult = deferred<IPCResult<void>>()
const second = c.pull('phi4')
expect(c.getSnapshot().error).toBeNull()
expect(c.getSnapshot().pullingModelId).toBe('phi4')
port.pullResult.resolve(ipcSuccess(undefined))
await expect(second).resolves.toBe('pulled')
expect(port.pullCalls).toEqual(['phi4', 'phi4'])
})
})
describe('OllamaModelPullController — 성공/진행', () => {
it('autoSelect면 받기 성공 후에만 setModel → llmModelId 저장 순으로 선택한다', async () => {
const port = makePort()
const onPulled = vi.fn()
const c = new OllamaModelPullController(port, { autoSelect: true, schedule: immediate })
c.setOnPulled(onPulled)
const p = c.pull('gemma4:e4b')
expect(port.setModelCalls).toEqual([])
port.pullResult.resolve(ipcSuccess(undefined))
await expect(p).resolves.toBe('selected')
expect(port.setModelCalls).toEqual(['gemma4:e4b'])
expect(port.persistCalls).toEqual(['gemma4:e4b'])
expect(c.getSnapshot().pullingModelId).toBeNull()
expect(onPulled).toHaveBeenCalledWith('gemma4:e4b')
})
it('autoSelect=false면 받기만 하고 선택하지 않는다', async () => {
const port = makePort()
const c = new OllamaModelPullController(port, { schedule: immediate })
const p = c.pull('qwen2.5:3b')
port.pullResult.resolve(ipcSuccess(undefined))
await expect(p).resolves.toBe('pulled')
expect(port.setModelCalls).toEqual([])
expect(port.persistCalls).toEqual([])
})
it('레이어 하나가 percent 100에 도달해도 완료로 보지 않는다 (IPC 결과로만 판정)', async () => {
const port = makePort()
const schedule = vi.fn()
const c = new OllamaModelPullController(port, { schedule })
c.attach()
const p = c.pull('gemma4:e4b')
port.emit({ modelId: 'gemma4:e4b', digest: 'sha256:small', percent: 100, completed: 100 })
port.emit({ modelId: 'gemma4:e4b', status: 'success', percent: 100 })
expect(c.getSnapshot().pullingModelId).toBe('gemma4:e4b')
expect(schedule).not.toHaveBeenCalled()
port.emit({ modelId: 'gemma4:e4b', digest: 'sha256:big', percent: 3, completed: 3 })
expect(c.getSnapshot().progress.percent).toBe(3)
port.pullResult.resolve(ipcSuccess(undefined))
await p
// 성공 후 100%를 잠시 보여 주고 지연 뒤 해제
expect(c.getSnapshot().progress.percent).toBe(100)
expect(c.getSnapshot().pullingModelId).toBe('gemma4:e4b')
expect(schedule).toHaveBeenCalledTimes(1)
const [fn, ms] = schedule.mock.calls[0] as [() => void, number]
expect(ms).toBe(1200)
fn()
expect(c.getSnapshot().pullingModelId).toBeNull()
})
it('다른 모델이나 결과가 난 뒤의 늦은 진행 이벤트는 무시한다', async () => {
const port = makePort()
const c = new OllamaModelPullController(port, { schedule: immediate })
c.attach()
port.emit({ modelId: 'phi4', percent: 50 })
expect(c.getSnapshot().pullingModelId).toBeNull()
const p = c.pull('gemma4:e4b')
port.emit({ modelId: 'phi4', percent: 77 })
expect(c.getSnapshot().progress.percent).toBe(0)
port.pullResult.resolve(ipcError(ErrorCode.LLMServerUnreachable, 'x'))
await p
port.emit({ modelId: 'gemma4:e4b', percent: 60 })
expect(c.getSnapshot().pullingModelId).toBeNull()
expect(c.getSnapshot().progress).toEqual(EMPTY_PULL_PROGRESS)
})
it('진행 중에 또 받기를 요청하면 busy로 무시한다', async () => {
const port = makePort()
const c = new OllamaModelPullController(port, { schedule: immediate })
const first = c.pull('gemma4:e4b')
await expect(c.pull('phi4')).resolves.toBe('busy')
expect(port.pullCalls).toEqual(['gemma4:e4b'])
port.pullResult.resolve(ipcSuccess(undefined))
await first
})
it('attach 해제 후에는 구독이 남지 않는다', () => {
const port = makePort()
const c = new OllamaModelPullController(port)
const detach = c.attach()
expect(port.listenerCount()).toBe(1)
detach()
expect(port.listenerCount()).toBe(0)
})
})
describe('OllamaModelPullController — select', () => {
it('setModel이 실패하면 llmModelId를 저장하지 않고 false + select 오류', async () => {
const port = makePort({
setModel: async () => ipcError(ErrorCode.LLMServerUnreachable, 'set failed'),
})
const c = new OllamaModelPullController(port)
await expect(c.select('phi4')).resolves.toBe(false)
expect(port.persistCalls).toEqual([])
expect(c.getSnapshot().error).toEqual({ modelId: 'phi4', stage: 'select', message: 'set failed' })
})
it('autoSelect 선택 단계가 실패하면 pull 결과는 failed이고 진행 상태는 풀린다', async () => {
const port = makePort({
persistModelId: async () => ipcError(ErrorCode.LLMServerUnreachable, 'config write failed'),
})
const c = new OllamaModelPullController(port, { autoSelect: true, schedule: immediate })
const p = c.pull('gemma4:e4b')
port.pullResult.resolve(ipcSuccess(undefined))
await expect(p).resolves.toBe('failed')
expect(c.getSnapshot().pullingModelId).toBeNull()
expect(c.getSnapshot().error?.stage).toBe('select')
})
it('구독자에게 상태 변경을 알린다', async () => {
const port = makePort()
const c = new OllamaModelPullController(port, { schedule: immediate })
const listener = vi.fn()
const unsub = c.subscribe(listener)
const p = c.pull('gemma4:e4b')
expect(listener).toHaveBeenCalled()
port.pullResult.resolve(ipcSuccess(undefined))
await p
unsub()
const calls = listener.mock.calls.length
c.clearError()
await c.select('gemma4:e4b')
expect(listener.mock.calls.length).toBe(calls)
})
})