From ce9830fece57fec08c7e5fbb7330218b6478571c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 3 Nov 2026 09:16:40 +0000 Subject: [PATCH] fix(studio-server): a project's signature no longer freezes the host while it walks the files (#7073) * fix(studio-server): a project's signature no longer freezes the host while it walks the files * style(studio-server): format the signature changes * fix(studio-server): keep the adapter for the preview's early-error script --- .../src/helpers/compositionInputs.test.ts | 28 +++++++++---------- .../src/helpers/compositionInputs.ts | 11 ++++---- .../src/helpers/projectSignature.ts | 11 ++++++-- packages/studio-server/src/routes/preview.ts | 12 ++++---- .../studio-server/src/routes/projects.test.ts | 23 +++++++++++++++ packages/studio-server/src/routes/projects.ts | 2 +- .../studio-server/src/routes/thumbnail.ts | 15 ++++++---- packages/studio-server/src/types.ts | 2 +- 8 files changed, 69 insertions(+), 35 deletions(-) diff --git a/packages/studio-server/src/helpers/compositionInputs.test.ts b/packages/studio-server/src/helpers/compositionInputs.test.ts index c2cdcfd4a4..227e30ce32 100644 --- a/packages/studio-server/src/helpers/compositionInputs.test.ts +++ b/packages/studio-server/src/helpers/compositionInputs.test.ts @@ -67,46 +67,46 @@ describe("a root write", () => { const rewrite = (dir: string, head: string, body: string) => writeFileSync(join(dir, "index.html"), html(head, body)); - it("moves every composition when the head changed, or when no head was seen yet", () => { + it("moves every composition when the head changed, or when no head was seen yet", async () => { const dir = project(files("", "x")); expect(compositionsAffectedBy(dir, "index.html")).toBeNull(); - compositionInputSignature(dir, "compositions/a.html", "s1"); + await compositionInputSignature(dir, "compositions/a.html", "s1"); rewrite(dir, "", "x"); expect(compositionsAffectedBy(dir, "index.html")).toBeNull(); }); - it("moves only the root when the head is as it was", () => { + it("moves only the root when the head is as it was", async () => { const dir = project(files("", "x")); - compositionInputSignature(dir, "compositions/a.html", "s1"); + await compositionInputSignature(dir, "compositions/a.html", "s1"); rewrite(dir, "", "moved"); expect(compositionsAffectedBy(dir, "index.html")).toEqual(["index.html"]); rewrite(dir, "", "moved again"); expect(compositionsAffectedBy(dir, "index.html")).toEqual(["index.html"]); }); - it("tells every subscriber of one head edit the same thing, even after a thumbnail request", () => { + it("tells every subscriber of one head edit the same thing, even after a thumbnail request", async () => { const dir = project(files("", "x")); - compositionInputSignature(dir, "compositions/a.html", "s1"); + await compositionInputSignature(dir, "compositions/a.html", "s1"); rewrite(dir, "", "x"); expect(compositionsAffectedBy(dir, "index.html")).toBeNull(); - compositionInputSignature(dir, "compositions/a.html", "s2"); + await compositionInputSignature(dir, "compositions/a.html", "s2"); expect(compositionsAffectedBy(dir, "index.html")).toBeNull(); }); - it("leaves a scene's input signature alone for a body edit, not for a head edit", () => { + it("leaves a scene's input signature alone for a body edit, not for a head edit", async () => { const dir = project(files("", "x")); - const before = compositionInputSignature(dir, "compositions/a.html", "s1"); + const before = await compositionInputSignature(dir, "compositions/a.html", "s1"); rewrite(dir, "", "moved"); - expect(compositionInputSignature(dir, "compositions/a.html", "s2")).toBe(before); + expect(await compositionInputSignature(dir, "compositions/a.html", "s2")).toBe(before); rewrite(dir, "", "moved"); - expect(compositionInputSignature(dir, "compositions/a.html", "s3")).not.toBe(before); + expect(await compositionInputSignature(dir, "compositions/a.html", "s3")).not.toBe(before); }); - it("changes the root's own input signature for a body edit", () => { + it("changes the root's own input signature for a body edit", async () => { const dir = project(files("", "x")); - const before = compositionInputSignature(dir, "index.html", "s1"); + const before = await compositionInputSignature(dir, "index.html", "s1"); rewrite(dir, "", "moved"); - expect(compositionInputSignature(dir, "index.html", "s2")).not.toBe(before); + expect(await compositionInputSignature(dir, "index.html", "s2")).not.toBe(before); }); }); diff --git a/packages/studio-server/src/helpers/compositionInputs.ts b/packages/studio-server/src/helpers/compositionInputs.ts index ce18fd4d31..f5180ff9aa 100644 --- a/packages/studio-server/src/helpers/compositionInputs.ts +++ b/packages/studio-server/src/helpers/compositionInputs.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { parseHTML } from "linkedom"; -import { createProjectSignature } from "./projectSignature.js"; +import { createProjectSignatureAsync } from "./projectSignature.js"; import { rootHeadContent } from "./subComposition.js"; import { resolveWithinProject } from "./safePath.js"; import { descendants } from "./compositionInsertion.js"; @@ -99,11 +99,11 @@ function rootHashes(read: SourceReader) { // What a thumbnail of `compPath` renders from: the project minus the compositions the root // mounts that `compPath` does not; for a scene the root counts as its head alone. -export function compositionInputSignature( +export async function compositionInputSignature( projectDir: string, compPath: string, projectSignature: string, -): string { +): Promise { const key = `${projectDir}\0${compPath}`; const known = inputSignatures.get(key); if (known?.projectSignature === projectSignature) return known.inputSignature; @@ -115,11 +115,12 @@ export function compositionInputSignature( ); let inputSignature: string; if (comp === ROOT_COMPOSITION) { - inputSignature = createProjectSignature(projectDir, excluded); + inputSignature = await createProjectSignatureAsync(projectDir, excluded); } else { const hashes = rootHashes(read); if (!rootMemos.has(projectDir)) rootMemos.set(projectDir, { ...hashes, affected: null }); - inputSignature = `${createProjectSignature(projectDir, excluded.add(ROOT_COMPOSITION))}:${hashes.head}`; + const rest = await createProjectSignatureAsync(projectDir, excluded.add(ROOT_COMPOSITION)); + inputSignature = `${rest}:${hashes.head}`; } inputSignatures.set(key, { projectSignature, inputSignature }); return inputSignature; diff --git a/packages/studio-server/src/helpers/projectSignature.ts b/packages/studio-server/src/helpers/projectSignature.ts index c521efd456..9f46f5633b 100644 --- a/packages/studio-server/src/helpers/projectSignature.ts +++ b/packages/studio-server/src/helpers/projectSignature.ts @@ -260,8 +260,13 @@ export function listProjectFiles( * provides one (the CLI invalidates its cache from the file watcher), falling * back to a direct computation. */ -export function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string { - return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir); +export async function resolveProjectSignature( + adapter: StudioApiAdapter, + projectDir: string, +): Promise { + return ( + (await adapter.getProjectSignature?.(projectDir)) ?? createProjectSignatureAsync(projectDir) + ); } /** The shared route opening: resolve the project (null → caller 404s) with its signature. */ @@ -271,7 +276,7 @@ export async function resolveProjectAndSignature( ): Promise<{ project: ResolvedProject; signature: string } | null> { const project = await adapter.resolveProject(projectId); if (!project) return null; - return { project, signature: resolveProjectSignature(adapter, project.dir) }; + return { project, signature: await resolveProjectSignature(adapter, project.dir) }; } interface SignaturePlan { diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index 9f42b58a11..8a2cf91bad 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -56,9 +56,8 @@ import { isGsapCoreUrl, } from "../helpers/gsapCoreScript.js"; import { - createProjectSignature, + createProjectSignatureAsync, resolveProjectAndSignature, - resolveProjectSignature, } from "../helpers/projectSignature.js"; import { createStudioMotionRenderBodyScript, @@ -369,6 +368,7 @@ kinds.push({name:known[name]===1?name:"other",origin:!path?"unknown":path===runt function injectStudioPreviewAugmentations( html: string, adapter: StudioApiAdapter, + signature: string, projectDir: string, activeCompositionPath: string, options: { capture: boolean; gsapBaseUrl: string }, @@ -388,7 +388,7 @@ function injectStudioPreviewAugmentations( marked, ``, ), - resolveProjectSignature(adapter, projectDir), + signature, ), options.gsapBaseUrl, ); @@ -536,6 +536,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo bundled = injectStudioPreviewAugmentations( ensureHfIds(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)), adapter, + signature, project.dir, mainCompositionPath, { capture, gsapBaseUrl }, @@ -549,7 +550,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo mediaCodecProbeCache, ); bundled = addScenePartsManifest(bundled, [`meta[name="${PROJECT_SIGNATURE_META}"]`]); - if (createProjectSignature(project.dir) === signature) { + if ((await createProjectSignatureAsync(project.dir)) === signature) { rememberPreview(builtKey, bundled); if (!capture) adapter.previewDocuments?.write(builtKey, bundled); } @@ -566,6 +567,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo let fallbackAugmented = injectStudioPreviewAugmentations( await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath), adapter, + signature, project.dir, fallback.compositionPath, { capture, gsapBaseUrl }, @@ -686,7 +688,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo html = withRootImportMap(html, baseHref); recordPreviewReferences(project.dir, html); html = ensureHfIds(await transformPreviewHtml(html, adapter, project, compPath)); - html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath, { + html = injectStudioPreviewAugmentations(html, adapter, signature, project.dir, compPath, { capture, gsapBaseUrl, }); diff --git a/packages/studio-server/src/routes/projects.test.ts b/packages/studio-server/src/routes/projects.test.ts index a484bc2649..e3be4bacf7 100644 --- a/packages/studio-server/src/routes/projects.test.ts +++ b/packages/studio-server/src/routes/projects.test.ts @@ -71,6 +71,29 @@ describe("GET /projects/:id/signature", () => { expect(await response.json()).toEqual({ signature: "cached-sig" }); }); + // fails without the fix: the polled signature walked every file synchronously, holding a host's event loop (a + // desktop app's main process) for the whole walk. + it("lets the event loop run while it walks a project", async () => { + const projectDir = createProjectDir(); + for (let i = 0; i < 300; i++) + writeFileSync(join(projectDir, `clip-${i}.html`), COMPOSITION_HTML); + const app = new Hono(); + registerProjectRoutes(app, createAdapter(projectDir)); + let turns = 0; + let answered = false; + const turn = () => { + if (answered) return; + turns++; + setImmediate(turn); + }; + setImmediate(turn); + + const response = await app.request("http://localhost/projects/demo/signature"); + answered = true; + expect(response.status).toBe(200); + expect(turns).toBeGreaterThan(0); + }); + it("computes a signature that moves when project files change", async () => { const projectDir = createProjectDir(); const app = new Hono(); diff --git a/packages/studio-server/src/routes/projects.ts b/packages/studio-server/src/routes/projects.ts index 70e0af4b82..291c1e2b95 100644 --- a/packages/studio-server/src/routes/projects.ts +++ b/packages/studio-server/src/routes/projects.ts @@ -43,7 +43,7 @@ export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): voi api.get("/projects/:id/signature", async (c) => { const project = await adapter.resolveProject(c.req.param("id")); if (!project) return c.json({ error: "not found" }, 404); - return c.json({ signature: resolveProjectSignature(adapter, project.dir) }); + return c.json({ signature: await resolveProjectSignature(adapter, project.dir) }); }); // Project file tree diff --git a/packages/studio-server/src/routes/thumbnail.ts b/packages/studio-server/src/routes/thumbnail.ts index 41dd36410d..7d355a0c17 100644 --- a/packages/studio-server/src/routes/thumbnail.ts +++ b/packages/studio-server/src/routes/thumbnail.ts @@ -17,7 +17,10 @@ import { createHash, randomUUID } from "node:crypto"; import type { StudioApiAdapter } from "../types.js"; import { STUDIO_MANUAL_EDITS_PATH } from "../helpers/manualEditsRenderScript.js"; import { compositionInputSignature } from "../helpers/compositionInputs.js"; -import { createProjectSignature, resolveProjectAndSignature } from "../helpers/projectSignature.js"; +import { + createProjectSignatureAsync, + resolveProjectAndSignature, +} from "../helpers/projectSignature.js"; import { STUDIO_MOTION_PATH } from "../helpers/studioMotionRenderScript.js"; import { thumbnailGenerationCoordinator } from "./thumbnailGenerationCoordinator.js"; import { requestSubPath } from "../helpers/requestSubPath.js"; @@ -134,7 +137,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v const source = htmlFile ? readFileOnce(htmlFile) : "not-a-file"; if (source === "not-a-file") return c.json({ error: "not found" }, 404); // Keyed on what this composition renders from, so editing one scene leaves the others cached. - const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature); + const inputSignature = await compositionInputSignature(project.dir, compPath, projectSignature); const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`); const rawSeekTime = url.searchParams.get("t"); @@ -246,15 +249,15 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v if (!generated) return null; const previewCopiesAtEnd = proxyActivityMark(project.dir); const afterGeneration = await resolveProjectAndSignature(adapter, project.id); - const inputsUnchanged = (signature: string) => - compositionInputSignature(project.dir, compPath, signature) === inputSignature; + const inputsUnchanged = async (signature: string) => + (await compositionInputSignature(project.dir, compPath, signature)) === inputSignature; // Both the adapter's signature and a fresh one: the adapter's can lag the watcher. if ( previewCopiesAtStart === null || previewCopiesAtEnd !== previewCopiesAtStart || afterGeneration?.project.dir !== project.dir || - !inputsUnchanged(afterGeneration.signature) || - !inputsUnchanged(createProjectSignature(project.dir)) + !(await inputsUnchanged(afterGeneration.signature)) || + !(await inputsUnchanged(await createProjectSignatureAsync(project.dir))) ) { // The browser may have rendered content written after this request // captured its cache identity. Return the pixels to this caller, diff --git a/packages/studio-server/src/types.ts b/packages/studio-server/src/types.ts index 85efaf974d..83da1f74cf 100644 --- a/packages/studio-server/src/types.ts +++ b/packages/studio-server/src/types.ts @@ -122,7 +122,7 @@ export interface StudioApiAdapter { ): Promise; /** Optional: a cached `createProjectSignature(dir)`; preview caching checks builds against it. */ - getProjectSignature?: (projectDir: string) => string; + getProjectSignature?: (projectDir: string) => string | Promise; invalidateProjectSignature?: (projectDir: string) => void; loadSharp?: () => Promise;