diff --git a/desktop/main/studio-local-models.ts b/desktop/main/studio-local-models.ts index c1cfe457e..33b861ee3 100644 --- a/desktop/main/studio-local-models.ts +++ b/desktop/main/studio-local-models.ts @@ -103,6 +103,8 @@ export class StudioLocalModels { private readonly inFlight = new Map>(); private engine: LocalEngineStatus | null = null; private engineRun: Promise | null = null; + /** A status ask from a catalog paint still running (engineStatusSoon), shared by the next paint. */ + private engineAsk: Promise | null = null; private readonly loads = new Map>(); /** The artifact this app loaded last, and whether with the vision projector (edit/reference). */ private loadedHere: { id: string; vision: boolean } | null = null; @@ -349,23 +351,65 @@ export class StudioLocalModels { return engine; } - /** Whether Core's sd.cpp engine is installed, and if not, whether this computer can take it. */ + /** + * Keep a definite answer about the engine. The first one, or one that changes the + * state, is pushed as an `engine` event so a page painted without it repaints. + */ + private knowEngine(engine: LocalEngineStatus): LocalEngineStatus { + const changed = this.engine?.state !== engine.state; + this.engine = engine; + if (changed) this.deps.emit({ type: "engine", engine: { ...engine } }); + return { ...engine }; + } + + /** + * Whether Core's sd.cpp engine is installed, and if not, whether this computer can take it. + * Only a definite answer is kept (ATO-1420): when Core's engine catalog did not answer, the + * state is not known, the reply says `missing` for now and the next call asks again. A kept + * guess would never be asked again, and Download would no longer refuse on a computer that + * cannot run local generation. + */ async engineStatus(): Promise { if (this.engineRun && this.engine) return { ...this.engine }; const client = await this.deps.ready(); const status = await client.call<{ install?: { state?: string; backend?: string; tag?: string } }>("/diffusion/status"); if (status.install?.state === "installed") { - this.engine = { state: "ready", message: "The image engine is ready.", backend: status.install.backend ?? null, tag: status.install.tag ?? null, progress: null, error: null }; - return { ...this.engine }; + return this.knowEngine({ state: "ready", message: "The image engine is ready.", backend: status.install.backend ?? null, tag: status.install.tag ?? null, progress: null, error: null }); } const catalog = await client.call<{ manifest?: unknown; host_backend_id?: string | null; host_reason?: string | null }>("/engine-builds/sd-cpp/catalog", {}, { timeoutMs: 30_000 }).catch(() => null); if (catalog?.manifest && !catalog.host_backend_id) { - this.engine = { state: "unsupported", message: `This computer cannot run local image models${catalog.host_reason ? ` (${catalog.host_reason.replace(/\.$/, "")})` : ""}. Use a cloud model instead.`, backend: null, tag: null, progress: null, error: null }; - return { ...this.engine }; + return this.knowEngine({ state: "unsupported", message: `This computer cannot run local image models${catalog.host_reason ? ` (${catalog.host_reason.replace(/\.$/, "")})` : ""}. Use a cloud model instead.`, backend: null, tag: null, progress: null, error: null }); } if (this.engine?.state === "failed") return { ...this.engine }; - this.engine = { state: "missing", message: "The image engine downloads once, the first time you generate locally.", backend: catalog?.host_backend_id ?? null, tag: null, progress: null, error: null }; - return { ...this.engine }; + const missing: LocalEngineStatus = { state: "missing", message: "The image engine downloads once, the first time you generate locally.", backend: catalog?.host_backend_id ?? null, tag: null, progress: null, error: null }; + // Core named a build for this computer: it can take the engine, a definite answer. + if (catalog?.manifest && catalog.host_backend_id) return this.knowEngine(missing); + return missing; + } + + /** + * The engine for a page about to paint: the kept answer at once, else Core asked for at + * most `waitMs` (ATO-1420). Null when Core is slow or down, so the catalog shows right away; + * the ask goes on, and a definite answer that lands later is pushed as an `engine` event. + * Asks overlapping in time share one call to Core. + */ + async engineStatusSoon(waitMs: number): Promise { + if (this.engine) return { ...this.engine }; + if (!this.engineAsk) { + this.engineAsk = this.engineStatus() + .catch(() => null) + .finally(() => { this.engineAsk = null; }); + } + const ask = this.engineAsk; + let resolveLate: (value: null) => void = () => {}; + const late = new Promise((resolve) => { resolveLate = resolve; }); + const timer = setTimeout(() => resolveLate(null), Math.max(0, waitMs)); + timer.unref?.(); + try { + return await Promise.race([ask, late]); + } finally { + clearTimeout(timer); + } } /** Install Core's sd.cpp engine when it is missing; joins an install in flight. */ diff --git a/desktop/main/studio-media.ts b/desktop/main/studio-media.ts index 9da1f6727..158ff0ea2 100644 --- a/desktop/main/studio-media.ts +++ b/desktop/main/studio-media.ts @@ -36,6 +36,8 @@ import { normalizeItem, normalizeJob, toCoreImageRequest, toCoreVideoRequest, ty export const OPENROUTER_MEDIA_BASE_URL = "https://openrouter.ai/api/v1"; /** A lease is let go this long after the last followed job ends. */ const LEASE_GRACE_MS = 60_000; +/** How long a catalog paint waits for the image engine's status before it shows without it. */ +export const ENGINE_ASK_ON_PAINT_MS = 1_500; export interface StudioMediaDeps { /** The managed data dir (`localModels.managed.dataDirOverride`, else `/models`), read per call. */ @@ -267,8 +269,10 @@ export class StudioMedia { if (m && m.modality === kind && typeof m.modelId === "string") loaded = m.modelId; } catch { /* Core down: families are still listed */ } /* The engine as last seen, else asked now: the page offers Download and local generation - by `engine.state`, and a cached null would hide "this computer cannot run them". */ - const engine = this.local.state().engine ?? (await this.local.engineStatus().catch(() => null)); + by `engine.state`, and a cached null would hide "this computer cannot run them". + The ask waits a moment only (ATO-1420): with Core slow or down the catalog still shows + at once, and the answer repaints the page when it lands (an `engine` event). */ + const engine = this.local.state().engine ?? (await this.local.engineStatusSoon(ENGINE_ASK_ON_PAINT_MS)); const extra: LocalSectionExtra = { machine: this.local.profile(), modelsDir: this.local.modelsDir(), engine }; try { const { doc, catalog, error } = await this.local.catalog(); diff --git a/desktop/test/studio-local.test.mjs b/desktop/test/studio-local.test.mjs index 703a5a8fc..b07e55997 100644 --- a/desktop/test/studio-local.test.mjs +++ b/desktop/test/studio-local.test.mjs @@ -366,3 +366,106 @@ test("the local catalog section asks for the engine when nothing has yet, so the assert.equal(section.engine, null); assert.ok(section.models.length > 0); }); + +/* ------------------------------------- engine status not kept on a failed ask (ATO-1420) */ + +test("an engine status Core could not answer is not kept: the next ask goes to Core again and Download still refuses", async () => { + const { StudioLocalModels } = require("../out/main/studio-local-models.js"); + const dataDir = join(base, "engine-flaky"); + let catalogAsks = 0; + const core = { + call: async (path) => { + if (path === "/diffusion/status") return { install: { state: "missing" }, model: { loaded: null } }; + if (path.startsWith("/engine-builds/sd-cpp/catalog")) { + catalogAsks++; + if (catalogAsks === 1) throw new Error("Core's engine catalog timed out"); + return { manifest: {}, host_backend_id: null, host_reason: "no supported GPU." }; + } + throw new Error(`unexpected ${path}`); + }, + }; + const events = []; + const lm = new StudioLocalModels({ + dataDir: () => dataDir, ready: async () => core, running: async () => null, + emit: (e) => events.push(e), request: async () => { throw new Error("offline"); }, + }); + const first = await lm.engineStatus(); + assert.equal(first.state, "missing", "the reply still has a shape the page reads"); + assert.equal(lm.state().engine, null, "a guess is not kept"); + assert.equal(events.filter((e) => e.type === "engine").length, 0); + // Download asks again and now learns this computer cannot run it. + await assert.rejects(lm.download("z-image:q4_k_m"), (e) => e.code === "ENGINE_UNSUPPORTED"); + assert.equal(catalogAsks, 2); + assert.equal(lm.state().engine.state, "unsupported"); + assert.deepEqual(events.filter((e) => e.type === "engine").map((e) => e.engine.state), ["unsupported"]); + assert.equal(events.filter((e) => e.type === "download").length, 0); +}); + +test("a definite engine answer is kept and not asked again by Download", async () => { + const { StudioLocalModels } = require("../out/main/studio-local-models.js"); + let catalogAsks = 0; + const core = { + call: async (path) => { + if (path === "/diffusion/status") return { install: { state: "missing" }, model: { loaded: null } }; + if (path.startsWith("/engine-builds/sd-cpp/catalog")) { catalogAsks++; return { manifest: {}, host_backend_id: null, host_reason: null }; } + throw new Error(`unexpected ${path}`); + }, + }; + const lm = new StudioLocalModels({ + dataDir: () => join(base, "engine-kept"), ready: async () => core, running: async () => null, + emit: () => {}, request: async () => { throw new Error("offline"); }, + }); + assert.equal((await lm.engineStatus()).state, "unsupported"); + await assert.rejects(lm.download("z-image:q4_k_m"), (e) => e.code === "ENGINE_UNSUPPORTED"); + assert.equal(catalogAsks, 1); +}); + +test("a catalog paint waits for a slow engine ask a moment only, shares it, and the late answer is pushed", async () => { + const { StudioLocalModels } = require("../out/main/studio-local-models.js"); + let release; + const held = new Promise((resolve) => { release = resolve; }); + let catalogAsks = 0; + const core = { + call: async (path) => { + if (path === "/diffusion/status") return { install: { state: "missing" }, model: { loaded: null } }; + if (path.startsWith("/engine-builds/sd-cpp/catalog")) { catalogAsks++; await held; return { manifest: {}, host_backend_id: null, host_reason: null }; } + throw new Error(`unexpected ${path}`); + }, + }; + const events = []; + const lm = new StudioLocalModels({ + dataDir: () => join(base, "engine-slow"), ready: async () => core, running: async () => null, + emit: (e) => events.push(e), request: async () => { throw new Error("offline"); }, + }); + const t0 = Date.now(); + assert.equal(await lm.engineStatusSoon(50), null); + assert.equal(await lm.engineStatusSoon(50), null); + assert.ok(Date.now() - t0 < 2_000, "the paint did not wait for Core"); + assert.equal(catalogAsks, 1, "overlapping paints share one ask"); + release(); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.deepEqual(events.filter((e) => e.type === "engine").map((e) => e.engine.state), ["unsupported"]); + assert.equal((await lm.engineStatusSoon(50)).state, "unsupported"); + assert.equal(catalogAsks, 1); +}); + +test("the local catalog section shows without waiting for an engine ask that hangs", async () => { + const { StudioMedia, ENGINE_ASK_ON_PAINT_MS } = require("../out/main/studio-media.js"); + const studio = new StudioMedia({ + dataDir: () => join(base, "section-slow"), stateDir: base, coreVersion: "0.0.0", openRouterKey: async () => null, + emitJob: () => {}, emitLocal: () => {}, + request: async () => { throw new Error("offline"); }, + coreRequest: () => { throw new Error("no Core download in this test"); }, + }); + studio.ready = async () => { throw new Error("no Core in this test"); }; + let asked = 0; + studio.local.engineStatus = () => { asked++; return new Promise(() => {}); }; + const t0 = Date.now(); + const section = await studio["localSection"]("image"); + const took = Date.now() - t0; + assert.equal(asked, 1); + assert.equal(section.engine, null); + assert.ok(section.models.length > 0, "the families are listed"); + assert.ok(ENGINE_ASK_ON_PAINT_MS <= 2_000); + assert.ok(took < ENGINE_ASK_ON_PAINT_MS + 1_500, `waited ${took} ms, not up to Core's 30 s`); +});