feat: add initDispatcher() for eager sidecar startup

The dispatcher was lazy-initialized on first AI request, but a race
condition meant the first call always missed it (dispatcherReady still
false) and fell through to cold per-request Python. initDispatcher()
starts the dispatcher eagerly and returns a Promise that resolves with
GPU status once ready (or after a timeout).
This commit is contained in:
SnapOtter
2026-04-30 18:48:15 +08:00
parent 6d5d0a3673
commit b344edf416
3 changed files with 135 additions and 1 deletions
+38
View File
@@ -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) ────────────────── // ── Per-request fallback (original implementation) ──────────────────
function runPythonPerRequest( function runPythonPerRequest(
+6 -1
View File
@@ -1,6 +1,11 @@
export { removeBackground } from "./background-removal.js"; export { removeBackground } from "./background-removal.js";
export type { DispatcherStatus } from "./bridge.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 { colorize } from "./colorization.js";
export type { DetectFacesResult, FaceRegion } from "./face-detection.js"; export type { DetectFacesResult, FaceRegion } from "./face-detection.js";
export { blurFaces, detectFaces } from "./face-detection.js"; export { blurFaces, detectFaces } from "./face-detection.js";
+91
View File
@@ -1127,3 +1127,94 @@ describe("bridge - dispatcher lifecycle via runPythonWithProgress", () => {
expect(callCount).toBe(3); 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);
});
});