fix(media-use): clean failed asset reservations (#2627)

* fix(media-use): clean failed asset reservations

* test(media-use): pin installer guidance exactly
This commit is contained in:
Miguel Ángel
2026-07-18 04:36:36 -04:00
committed by GitHub
parent 7f76170956
commit 49113eb084
8 changed files with 306 additions and 95 deletions
+5 -3
View File
@@ -6,14 +6,16 @@ import { track } from "./telemetry.mjs";
// needs — so anything below this can't authenticate for free usage at all.
export const HEYGEN_MIN_VERSION = "0.3.0";
// Free-usage path is OAuth (`--oauth` → subscription/free credits); `--api-key`
// bills API credits, so the onboarding steers to OAuth.
// bills API credits, so the onboarding steers to OAuth. Keep pipe-to-shell
// installer text out of the runtime module; the docs are the safer source of
// truth for platform-specific setup.
export const HEYGEN_INSTALL_COMMAND =
"curl -fsSL https://static.heygen.ai/cli/install.sh | bash && heygen auth login --oauth";
"Install the CLI from https://developers.heygen.com/cli, then run: heygen auth login --oauth";
export const HEYGEN_AUTH_COMMAND = "heygen auth login --oauth";
export const HEYGEN_UPDATE_COMMAND = "heygen update";
export const HEYGEN_CLIENT_SOURCE_ARGV = ["--headers", "X-HeyGen-Client-Source: media-use"];
export const HEYGEN_NOT_FOUND_MESSAGE = `media-use: heygen CLI not found — it's the free path for bgm/image/voice/avatar-video. Install: ${HEYGEN_INSTALL_COMMAND}`;
export const HEYGEN_NOT_FOUND_MESSAGE = `media-use: heygen CLI not found — it's the free path for bgm/image/voice/avatar-video. ${HEYGEN_INSTALL_COMMAND}`;
export const HEYGEN_NOT_AUTHENTICATED_MESSAGE = `media-use: heygen CLI not authenticated (free usage) — run: ${HEYGEN_AUTH_COMMAND}`;
export const HEYGEN_OUTDATED_MESSAGE = `media-use: heygen CLI is outdated — run: ${HEYGEN_UPDATE_COMMAND} (need >= v${HEYGEN_MIN_VERSION})`;
@@ -1,6 +1,6 @@
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { allocateId } from "./manifest.mjs";
import { withReservedFile, withReservedFileSync } from "./manifest.mjs";
import { freezeUrl } from "./freeze.mjs";
import { tokenOverlap } from "./match.mjs";
import { buildCube } from "./cube-build.mjs";
@@ -183,19 +183,27 @@ export async function freezeLibraryLut(match, { projectDir, type, localOnly = fa
// to deterministic buildCube params when offline (--local-only) or if the
// download/validation fails, so resolution is never blocked on the network.
if (match.url && !localOnly) {
const { id, localPath } = allocateId(projectDir, type, ".cube");
const fullPath = join(projectDir, localPath);
const tmpPath = `${fullPath}.tmp`;
try {
// Download + validate at a .tmp path, then atomically rename. A crash
// (SIGKILL/OOM) between write and validate can't orphan an invalid .cube
// at the final path — only a validated cube is ever renamed into place.
await freezeUrl(match.url, tmpPath);
assertValidCubeFile(tmpPath, `downloaded library LUT ${match.id} failed validation`);
renameSync(tmpPath, fullPath);
return libraryRecord(match, { id, localPath, fullPath, via: "url" });
return await withReservedFile(
projectDir,
type,
".cube",
async ({ id, localPath, fullPath }) => {
const tmpPath = `${fullPath}.tmp`;
try {
// Download + validate at a .tmp path, then atomically rename. A crash
// (SIGKILL/OOM) between write and validate can't orphan an invalid .cube
// at the final path — only a validated cube is ever renamed into place.
await freezeUrl(match.url, tmpPath);
assertValidCubeFile(tmpPath, `downloaded library LUT ${match.id} failed validation`);
renameSync(tmpPath, fullPath);
return libraryRecord(match, { id, localPath, fullPath, via: "url" });
} finally {
rmSync(tmpPath, { force: true });
}
},
);
} catch (err) {
rmSync(tmpPath, { force: true });
if (!match.params) {
throw new Error(`failed to freeze library LUT ${match.id}: ${err.message}`);
}
@@ -204,26 +212,25 @@ export async function freezeLibraryLut(match, { projectDir, type, localOnly = fa
}
if (match.params) {
const { id, localPath } = allocateId(projectDir, type, ".cube");
const fullPath = join(projectDir, localPath);
const tmpPath = `${fullPath}.tmp`;
try {
const cube = buildCube(match.params);
assertValidCubeText(cube, `invalid library LUT ${match.id}`);
// Write + validate at .tmp, then atomic rename — same no-orphan guarantee
// as the url path above.
writeFileSync(tmpPath, cube);
assertValidCubeFile(tmpPath, `invalid frozen LUT ${localPath}`);
renameSync(tmpPath, fullPath);
} catch (err) {
rmSync(tmpPath, { force: true });
throw err;
}
return libraryRecord(match, {
id,
localPath,
fullPath,
via: match.url ? "params-fallback" : "params",
return withReservedFileSync(projectDir, type, ".cube", ({ id, localPath, fullPath }) => {
const tmpPath = `${fullPath}.tmp`;
try {
const cube = buildCube(match.params);
assertValidCubeText(cube, `invalid library LUT ${match.id}`);
// Write + validate at .tmp, then atomic rename — same no-orphan guarantee
// as the url path above.
writeFileSync(tmpPath, cube);
assertValidCubeFile(tmpPath, `invalid frozen LUT ${localPath}`);
renameSync(tmpPath, fullPath);
return libraryRecord(match, {
id,
localPath,
fullPath,
via: match.url ? "params-fallback" : "params",
});
} finally {
rmSync(tmpPath, { force: true });
}
});
}
@@ -1,5 +1,5 @@
import { strict as assert } from "node:assert";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
@@ -128,3 +128,33 @@ test("url library entries respect localOnly and freeze through fetch", async ()
rmSync(projectDir, { recursive: true, force: true });
}
});
test("failed URL library freeze releases its reservation", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "mu-lut-failure-"));
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = async () => ({
ok: true,
headers: { get: () => "12" },
body: [Buffer.from("not a cube\n")],
});
await assert.rejects(
freezeLibraryLut(
{
kind: "library",
id: "broken-cdn-look",
description: "Broken CDN look",
tags: ["broken"],
intensity: 1,
url: "https://example.com/broken.cube",
},
{ projectDir, type: "lut" },
),
/failed to freeze library LUT/,
);
assert.deepStrictEqual(readdirSync(join(projectDir, ".media/luts")), []);
} finally {
globalThis.fetch = originalFetch;
rmSync(projectDir, { recursive: true, force: true });
}
});
+37
View File
@@ -190,3 +190,40 @@ export function allocateId(projectDir, type, ext) {
return { id, localPath };
});
}
function reservedFile(projectDir, type, ext) {
const allocation = allocateId(projectDir, type, ext);
return { ...allocation, fullPath: join(projectDir, allocation.localPath) };
}
function rollbackReservation(reservation) {
rmSync(reservation.fullPath, { force: true });
}
// A reservation is committed only when populate returns a non-null value.
// Throwing/rejecting or returning null means no usable asset was produced, so
// the placeholder must be released. Keeping this transaction beside allocateId
// prevents individual provider/cache/LUT paths from forgetting the rollback.
export function withReservedFileSync(projectDir, type, ext, populate) {
const reservation = reservedFile(projectDir, type, ext);
try {
const result = populate(reservation);
if (result == null) rollbackReservation(reservation);
return result;
} catch (error) {
rollbackReservation(reservation);
throw error;
}
}
export async function withReservedFile(projectDir, type, ext, populate) {
const reservation = reservedFile(projectDir, type, ext);
try {
const result = await populate(reservation);
if (result == null) rollbackReservation(reservation);
return result;
} catch (error) {
rollbackReservation(reservation);
throw error;
}
}
+42 -1
View File
@@ -1,5 +1,13 @@
import { strict as assert } from "node:assert";
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import {
mkdtempSync,
rmSync,
readFileSync,
writeFileSync,
mkdirSync,
existsSync,
readdirSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
@@ -9,6 +17,7 @@ import {
findByEntity,
nextId,
allocateId,
withReservedFileSync,
normalizePrompt,
manifestPath,
mediaDir,
@@ -164,6 +173,38 @@ function runTests() {
cleanup();
});
test("failed reservation rollback preserves another completed reservation", () => {
setup();
const kept = withReservedFileSync(tmp, "bgm", ".wav", (reservation) => {
writeFileSync(reservation.fullPath, "completed asset");
return reservation;
});
assert.throws(
() =>
withReservedFileSync(tmp, "bgm", ".wav", () => {
throw new Error("populate failed");
}),
/populate failed/,
);
assert.equal(kept.id, "bgm_001");
assert.equal(readFileSync(kept.fullPath, "utf8"), "completed asset");
assert.deepStrictEqual(readdirSync(typeDirPath(tmp, "bgm")), ["bgm_001.wav"]);
assert.equal(allocateId(tmp, "bgm", ".wav").id, "bgm_002");
cleanup();
});
test("empty reservation result releases the placeholder", () => {
setup();
assert.equal(
withReservedFileSync(tmp, "image", ".jpg", () => null),
null,
);
assert.deepStrictEqual(readdirSync(typeDirPath(tmp, "image")), []);
cleanup();
});
test("findByEntity matches case-insensitively", () => {
setup();
appendRecord(tmp, makeRecord({ entity: "GitHub", type: "icon" }));