perf(studio): stream nitro repair progress over SSE and allow cancelling
This commit is contained in:
1 parent
ba81d16f00
commit
9d571e0c29
4 files changed
+246
-23
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
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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 };
|
||||||
|
|||||||
Reference in new issue
Block a user