Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 51 additions & 7 deletions desktop/main/studio-local-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ export class StudioLocalModels {
private readonly inFlight = new Map<string, Promise<void>>();
private engine: LocalEngineStatus | null = null;
private engineRun: Promise<void> | null = null;
/** A status ask from a catalog paint still running (engineStatusSoon), shared by the next paint. */
private engineAsk: Promise<LocalEngineStatus | null> | null = null;
private readonly loads = new Map<string, Promise<void>>();
/** The artifact this app loaded last, and whether with the vision projector (edit/reference). */
private loadedHere: { id: string; vision: boolean } | null = null;
Expand Down Expand Up @@ -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<LocalEngineStatus> {
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<LocalEngineStatus | null> {
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<null>((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. */
Expand Down
8 changes: 6 additions & 2 deletions desktop/main/studio-media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<stateDir>/models`), read per call. */
Expand Down Expand Up @@ -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();
Expand Down
103 changes: 103 additions & 0 deletions desktop/test/studio-local.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
});