perf(studio): stream nitro repair progress over SSE and allow cancelling
CI / check (push) Successful in 4m23s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m43s

This commit is contained in:
openhands committed 2026-09-19 13:12:45 +02:00
1 parent ba81d16f00
commit 9d571e0c29
4 files changed
+249 -26

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+56
View File
@@ -88,6 +88,7 @@ vi.mock("node:fs", async (orig) => {
import { import {
autoCleanFakeNitros, autoCleanFakeNitros,
deleteNitroCleanupFiles, deleteNitroCleanupFiles,
type NitroRepairProgress,
repairBrokenNitros, repairBrokenNitros,
scanFakeBrokenNitros, scanFakeBrokenNitros,
} from "@/lib/services/nitro-cleanup"; } from "@/lib/services/nitro-cleanup";
@@ -471,4 +472,59 @@ describe("repairBrokenNitros", () => {
expect(result.failed).toBe(1); expect(result.failed).toBe(1);
expect(result.errors[0]).toMatch(/could not re-obtain/); expect(result.errors[0]).toMatch(/could not re-obtain/);
}); });
it("reports per-bundle progress while re-downloading", async () => {
getTargetsFn.mockResolvedValue({
nitroDir: "/a/nitro",
swfDir: "/a/swf",
iconDir: "/a/icons",
mirrorDirs: [],
});
readFileFn.mockResolvedValue(Buffer.from("nitro-data"));
parseNitroBundle.mockReturnValue({});
downloadFileFn.mockResolvedValue({ ok: true, size: 100 });
const events: NitroRepairProgress[] = [];
const result = await repairBrokenNitros(["a.nitro", "b.nitro"], {
onProgress: (progress) => events.push(progress),
});
expect(result.repaired).toBe(2);
expect(events).toHaveLength(2);
expect(events.map((e) => e.completed).sort()).toEqual([1, 2]);
expect(events[events.length - 1]).toMatchObject({
total: 2,
ok: 2,
failed: 0,
});
expect(events[0].total).toBe(2);
});
it("counts successes and failures in repair progress", async () => {
getTargetsFn.mockResolvedValue({
nitroDir: "/a/nitro",
swfDir: "/a/swf",
iconDir: "/a/icons",
mirrorDirs: [],
});
readFileFn.mockResolvedValue(Buffer.from("nitro-data"));
parseNitroBundle.mockReturnValue({});
downloadFileFn.mockImplementation(async (url: string) => ({
ok: url.includes("a.nitro"),
size: 100,
error: url.includes("b.nitro") ? "HTTP 404" : undefined,
}));
const events: NitroRepairProgress[] = [];
await repairBrokenNitros(["a.nitro", "b.nitro"], {
onProgress: (progress) => events.push(progress),
});
expect(events.map((e) => e.completed).sort()).toEqual([1, 2]);
expect(events[events.length - 1]).toMatchObject({
total: 2,
ok: 1,
failed: 1,
});
});
}); });
+30 -7
View File
@@ -584,15 +584,28 @@ async function copyFileToDirs(
return errors; return errors;
} }
export interface NitroRepairProgress {
completed: number;
total: number;
ok: number;
failed: number;
fileName?: string;
error?: string;
}
/** /**
* Re-download broken .nitro bundles from the configured nitro sources. The * Re-download broken .nitro bundles from the configured nitro sources. The
* file stem matched an item in `items_base`, so the same classname candidates * file stem matched an item in `items_base`, so the same classname candidates
* used by repair-nitros.ts are tried; every downloaded bundle is validated * used by repair-nitros.ts are tried; every downloaded bundle is validated
* before it replaces the broken copies across all nitro directories. * before it replaces the broken copies across all nitro directories. Bundles
* are repaired in parallel (pool of 6) and `onProgress` is called after each
* one settles, so a huge batch can be streamed to the client and cancelled.
*/ */
export async function repairBrokenNitros( export async function repairBrokenNitros(
fileNames: string[], fileNames: string[],
options: { onProgress?: (progress: NitroRepairProgress) => void } = {},
): Promise<NitroRepairResult> { ): Promise<NitroRepairResult> {
const { onProgress } = options;
const targets = await getFurniAssetWriteTargets(); const targets = await getFurniAssetWriteTargets();
const nitroDirs = dirsForKind(targets, "nitro"); const nitroDirs = dirsForKind(targets, "nitro");
let sources = (await listSources().catch(() => [] as never)).filter( let sources = (await listSources().catch(() => [] as never)).filter(
@@ -604,14 +617,23 @@ export async function repairBrokenNitros(
let failed = 0; let failed = 0;
const errors: string[] = []; const errors: string[] = [];
const files: NitroRepairResult["files"] = []; const files: NitroRepairResult["files"] = [];
const uniqueFileNames = [...new Set(fileNames)];
const total = uniqueFileNames.length;
let completed = 0;
const finish = (fileName: string, ok: boolean, error?: string) => {
completed++;
if (ok) repaired++;
else failed++;
onProgress?.({ completed, total, ok: repaired, failed, fileName, error });
};
await runPool([...new Set(fileNames)], 6, async (rawName) => { await runPool(uniqueFileNames, 6, async (rawName) => {
const fileName = String(rawName ?? ""); const fileName = String(rawName ?? "");
if (!NITRO_FILE_RE.test(fileName) || !fileName.endsWith(".nitro")) { if (!NITRO_FILE_RE.test(fileName) || !fileName.endsWith(".nitro")) {
failed++;
const message = `Invalid nitro file name: ${fileName}`; const message = `Invalid nitro file name: ${fileName}`;
errors.push(message); errors.push(message);
files.push({ fileName, ok: false, error: message }); files.push({ fileName, ok: false, error: message });
finish(fileName, false, message);
return; return;
} }
const name = fileName.slice(0, -".nitro".length); const name = fileName.slice(0, -".nitro".length);
@@ -648,16 +670,16 @@ export async function repairBrokenNitros(
nitroDirs, nitroDirs,
); );
if (writeErrors.length === 0) { if (writeErrors.length === 0) {
repaired++;
files.push({ fileName, ok: true }); files.push({ fileName, ok: true });
finish(fileName, true);
} else { } else {
failed++;
errors.push(...writeErrors); errors.push(...writeErrors);
files.push({ files.push({
fileName, fileName,
ok: false, ok: false,
error: writeErrors[0], error: writeErrors[0],
}); });
finish(fileName, false, writeErrors[0]);
} }
return; return;
} }
@@ -670,13 +692,14 @@ export async function repairBrokenNitros(
} }
} }
} }
failed++; const message = "could not re-obtain bundle from any nitro source";
errors.push(`could not re-obtain ${fileName} from any nitro source`); errors.push(`could not re-obtain ${fileName} from any nitro source`);
files.push({ files.push({
fileName, fileName,
ok: false, ok: false,
error: "could not re-obtain bundle from any nitro source", error: message,
}); });
finish(fileName, false, message);
}); });
return { repaired, failed, errors, files }; return { repaired, failed, errors, files };