fix(snapshots): use streamed response to avoid 1 MiB buffer limit on large repos (#771)

This commit is contained in:
Nico 2026-04-09 23:47:36 +02:00 committed by GitHub
parent 70c7de1efc
commit 863fbfc5cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 92 additions and 6 deletions

View file

@ -0,0 +1,63 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as nodeModule from "../../../node";
import { snapshots } from "../snapshots";
import type { SafeSpawnParams } from "../../../node";
import type { ResticDeps } from "../../types";
const mockDeps: ResticDeps = {
resolveSecret: async (s) => s,
getOrganizationResticPassword: async () => "org-restic-password",
resticCacheDir: "/tmp/restic-cache",
resticPassFile: "/tmp/restic.pass",
defaultExcludes: ["/tmp/restic.pass", "/var/lib/zerobyte/repositories"],
};
const config = {
backend: "local" as const,
path: "/tmp/restic-repo",
isExistingRepository: true,
customPassword: "custom-password",
};
const getOnStdout = (params: SafeSpawnParams) => {
if (params.stdoutMode === "raw") {
return undefined;
}
return params.onStdout;
};
afterEach(() => {
vi.restoreAllMocks();
});
describe("snapshots command", () => {
test("parses a streamed multi-line JSON array", async () => {
const snapshotsOutput = [
{
hostname: "host",
id: "snapshot-1",
paths: ["/data"],
short_id: "snapshot-1",
tags: ["daily"],
time: "2025-01-01T00:00:00Z",
},
];
vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
vi.spyOn(nodeModule, "safeSpawn").mockImplementation((params) => {
const onStdout = getOnStdout(params);
for (const line of JSON.stringify(snapshotsOutput, null, 2).split("\n")) {
onStdout?.(line);
}
return Promise.resolve({ exitCode: 0, summary: "", error: "" });
});
const result = await snapshots(config, { organizationId: "org-1" }, mockDeps);
expect(result).toEqual(snapshotsOutput);
});
});

View file

@ -5,7 +5,7 @@ import { buildRepoUrl } from "../helpers/build-repo-url";
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
import { resticSnapshotSummarySchema } from "../restic-dto";
import type { RepositoryConfig } from "../schemas";
import { logger, safeExec } from "../../node";
import { logger, safeSpawn } from "../../node";
import type { ResticDeps } from "../types";
const snapshotInfoSchema = z.object({
@ -43,15 +43,24 @@ export const snapshots = async (
addCommonArgs(args, env, config);
const res = await safeExec({ command: "restic", args, env });
const stdoutLines: string[] = [];
const res = await safeSpawn({
command: "restic",
args,
env,
onStdout: (line) => {
stdoutLines.push(line);
},
});
await cleanupTemporaryKeys(env, deps);
if (res.exitCode !== 0) {
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
throw new Error(`Restic snapshots retrieval failed: ${res.stderr}`);
const errorMessage = res.stderr || res.error;
logger.error(`Restic snapshots retrieval failed: ${errorMessage}`);
throw new Error(`Restic snapshots retrieval failed: ${errorMessage}`);
}
const result = snapshotInfoSchema.array().safeParse(JSON.parse(res.stdout));
const result = snapshotInfoSchema.array().safeParse(JSON.parse(stdoutLines.join("\n")));
if (!result.success) {
logger.error(`Restic snapshots output validation failed: ${result.error.message}`);

View file

@ -0,0 +1,14 @@
import { describe, expect, test } from "vitest";
import { safeExec } from "../spawn";
describe("safeExec", () => {
test("falls back to the process error message when stderr is empty", async () => {
const result = await safeExec({
command: process.execPath,
args: ["-e", "process.stdout.write('a'.repeat(2 * 1024 * 1024))"],
});
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("stdout maxBuffer length exceeded");
});
});

View file

@ -28,7 +28,7 @@ export const safeExec = async ({ command, args = [], env = {}, ...rest }: ExecPr
return {
exitCode: typeof execError.code === "number" ? execError.code : 1,
stdout: execError.stdout || "",
stderr: timedOut ? "Command timed out before completing" : execError.stderr || "",
stderr: timedOut ? "Command timed out before completing" : execError.stderr || execError.message || "",
timedOut,
};
}