diff --git a/packages/ai/src/bridge.ts b/packages/ai/src/bridge.ts index c54ab084..51721907 100644 --- a/packages/ai/src/bridge.ts +++ b/packages/ai/src/bridge.ts @@ -330,6 +330,44 @@ export function shutdownDispatcher(): void { } } +/** + * Eagerly start the Python dispatcher and wait for its readiness signal. + * Returns the GPU status once ready, or {ready: false} on timeout/failure. + * Safe to call multiple times -- idempotent if the dispatcher is already running. + */ +export function initDispatcher(timeoutMs = 30_000): Promise<{ ready: boolean; gpu: boolean }> { + if (dispatcherReady) { + return Promise.resolve({ ready: true, gpu: dispatcherGpuAvailable }); + } + if (dispatcherFailed) { + return Promise.resolve({ ready: false, gpu: false }); + } + + const proc = getDispatcher(); + if (!proc) { + return Promise.resolve({ ready: false, gpu: false }); + } + + return new Promise((resolve) => { + const timer = setTimeout(() => { + clearInterval(poll); + resolve({ ready: false, gpu: false }); + }, timeoutMs); + + const poll = setInterval(() => { + if (dispatcherReady) { + clearTimeout(timer); + clearInterval(poll); + resolve({ ready: true, gpu: dispatcherGpuAvailable }); + } else if (dispatcherFailed) { + clearTimeout(timer); + clearInterval(poll); + resolve({ ready: false, gpu: false }); + } + }, 50); + }); +} + // ── Per-request fallback (original implementation) ────────────────── function runPythonPerRequest( diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 2e3c4c4a..a750aa3a 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,6 +1,11 @@ export { removeBackground } from "./background-removal.js"; export type { DispatcherStatus } from "./bridge.js"; -export { getDispatcherStatus, isGpuAvailable, shutdownDispatcher } from "./bridge.js"; +export { + getDispatcherStatus, + initDispatcher, + isGpuAvailable, + shutdownDispatcher, +} from "./bridge.js"; export { colorize } from "./colorization.js"; export type { DetectFacesResult, FaceRegion } from "./face-detection.js"; export { blurFaces, detectFaces } from "./face-detection.js"; diff --git a/tests/unit/ai/bridge.test.ts b/tests/unit/ai/bridge.test.ts index df56142b..d4bb652a 100644 --- a/tests/unit/ai/bridge.test.ts +++ b/tests/unit/ai/bridge.test.ts @@ -1127,3 +1127,94 @@ describe("bridge - dispatcher lifecycle via runPythonWithProgress", () => { expect(callCount).toBe(3); }); }); + +describe("bridge - initDispatcher", () => { + let initDispatcher: typeof import("../../../packages/ai/src/bridge.js").initDispatcher; + let getDispatcherStatus: typeof import("../../../packages/ai/src/bridge.js").getDispatcherStatus; + let shutdownDispatcher: typeof import("../../../packages/ai/src/bridge.js").shutdownDispatcher; + + beforeEach(async () => { + vi.resetModules(); + vi.mocked(spawn).mockReset(); + + const mod = await import("../../../packages/ai/src/bridge.js"); + initDispatcher = mod.initDispatcher; + getDispatcherStatus = mod.getDispatcherStatus; + shutdownDispatcher = mod.shutdownDispatcher; + }); + + afterEach(() => { + shutdownDispatcher(); + vi.restoreAllMocks(); + }); + + it("resolves with ready=true and gpu status after dispatcher emits readiness", async () => { + const mock = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mock.process); + + const promise = initDispatcher(); + + // Dispatcher emits readiness signal + mock.stderr.emit("data", Buffer.from('{"ready": true, "gpu": true}\n')); + + const result = await promise; + expect(result).toEqual({ ready: true, gpu: true }); + expect(getDispatcherStatus().ready).toBe(true); + expect(getDispatcherStatus().gpu).toBe(true); + }); + + it("resolves with ready=false when dispatcher fails with ENOENT", async () => { + const mock = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mock.process); + + const promise = initDispatcher(); + + const err = new Error("spawn ENOENT") as NodeJS.ErrnoException; + err.code = "ENOENT"; + mock.emitEvent("error", err); + + const result = await promise; + expect(result).toEqual({ ready: false, gpu: false }); + }); + + it("resolves with ready=false after timeout when dispatcher never signals ready", async () => { + vi.useFakeTimers(); + const mock = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mock.process); + + const promise = initDispatcher(500); + + vi.advanceTimersByTime(600); + + const result = await promise; + expect(result).toEqual({ ready: false, gpu: false }); + + vi.useRealTimers(); + }); + + it("resolves with gpu=false when dispatcher reports no GPU", async () => { + const mock = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mock.process); + + const promise = initDispatcher(); + + mock.stderr.emit("data", Buffer.from('{"ready": true, "gpu": false}\n')); + + const result = await promise; + expect(result).toEqual({ ready: true, gpu: false }); + }); + + it("is idempotent -- second call returns same result without respawning", async () => { + const mock = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mock.process); + + const promise1 = initDispatcher(); + mock.stderr.emit("data", Buffer.from('{"ready": true, "gpu": true}\n')); + await promise1; + + const result2 = await initDispatcher(); + expect(result2).toEqual({ ready: true, gpu: true }); + // spawn should only have been called once + expect(spawn).toHaveBeenCalledTimes(1); + }); +});