Skip to content

Commit ca60d00

Browse files
committed
fix(backup): gracefully skip session files deleted during backup create
`openclaw backup create` could fail with ENOENT when the gateway's session compaction deleted a session file between enumeration and tar archiving. Replace `tar.c` with `tar.Pack` so that ENOENT errors during archiving are caught and skipped rather than failing the entire backup. Skipped files are reported in the backup summary. Fixes #67417
1 parent 489404d commit ca60d00

2 files changed

Lines changed: 107 additions & 5 deletions

File tree

src/commands/backup.test-support.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,48 @@ import { resolveBackupPlanFromPaths } from "./backup-shared.js";
88
export const tarCreateMock = vi.fn();
99
export const backupVerifyCommandMock = vi.fn();
1010

11+
class MockPack {
12+
private options: { file: string; onWriteEntry?: (entry: unknown) => void };
13+
private files: string[] = [];
14+
private stream?: NodeJS.WritableStream & NodeJS.EventEmitter;
15+
private errorListeners: Array<(err: unknown) => void> = [];
16+
17+
constructor(options: { file: string; onWriteEntry?: (entry: unknown) => void }) {
18+
this.options = options;
19+
}
20+
21+
pipe(stream: NodeJS.WritableStream & NodeJS.EventEmitter) {
22+
this.stream = stream;
23+
return stream;
24+
}
25+
26+
add(file: string) {
27+
this.files.push(file);
28+
}
29+
30+
on(event: string, listener: (err: unknown) => void) {
31+
if (event === "error") {
32+
this.errorListeners.push(listener);
33+
}
34+
return this;
35+
}
36+
37+
async end() {
38+
try {
39+
await tarCreateMock(this.options, this.files);
40+
this.stream?.emit("close" as never);
41+
} catch (err) {
42+
for (const listener of this.errorListeners) {
43+
listener(err);
44+
}
45+
this.stream?.emit("error" as never, err);
46+
}
47+
}
48+
}
49+
1150
vi.mock("tar", () => ({
1251
c: tarCreateMock,
52+
Pack: MockPack,
1353
}));
1454

1555
vi.mock("./backup-verify.js", () => ({

src/infra/backup-create.ts

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { randomUUID } from "node:crypto";
2-
import { constants as fsConstants } from "node:fs";
2+
import { constants as fsConstants, createWriteStream } from "node:fs";
33
import fs from "node:fs/promises";
44
import os from "node:os";
55
import path from "node:path";
@@ -12,7 +12,8 @@ import {
1212
resolveBackupPlanFromDisk,
1313
} from "../commands/backup-shared.js";
1414
import { isPathWithin } from "../commands/cleanup-utils.js";
15-
import { resolveHomeDir, resolveUserPath } from "../utils.js";
15+
import { isNotFoundPathError } from "../infra/path-guards.js";
16+
import { resolveHomeDir, resolveUserPath, shortenHomePath } from "../utils.js";
1617
import { resolveRuntimeServiceVersion } from "../version.js";
1718

1819
export type BackupCreateOptions = {
@@ -127,10 +128,63 @@ function buildTempArchivePath(outputPath: string): string {
127128
return `${outputPath}.${randomUUID()}.tmp`;
128129
}
129130

131+
async function createTarArchiveWithMissingSkip(
132+
params: {
133+
file: string;
134+
gzip?: boolean;
135+
portable?: boolean;
136+
preservePaths?: boolean;
137+
onWriteEntry?: (entry: unknown) => void;
138+
},
139+
files: string[],
140+
): Promise<string[]> {
141+
const skipped: string[] = [];
142+
const pack = new tar.Pack({
143+
file: params.file,
144+
gzip: params.gzip,
145+
portable: params.portable,
146+
preservePaths: params.preservePaths,
147+
onWriteEntry: params.onWriteEntry,
148+
});
149+
const stream = createWriteStream(params.file);
150+
pack.pipe(stream);
151+
152+
await new Promise<void>((resolve, reject) => {
153+
stream.on("error", reject);
154+
stream.on("close", resolve);
155+
pack.on("error", (err: unknown) => {
156+
if (isNotFoundPathError(err)) {
157+
const p = (err as NodeJS.ErrnoException).path;
158+
if (typeof p === "string") {
159+
skipped.push(p);
160+
}
161+
return;
162+
}
163+
reject(err instanceof Error ? err : new Error(String(err)));
164+
});
165+
166+
for (const file of files) {
167+
pack.add(file);
168+
}
169+
pack.end();
170+
});
171+
172+
return skipped;
173+
}
174+
130175
function isLinkUnsupportedError(code: string | undefined): boolean {
131176
return code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EPERM";
132177
}
133178

179+
function resolveAssetKindForPath(filePath: string, assets: BackupAsset[]): string {
180+
for (const asset of assets) {
181+
if (filePath === asset.sourcePath || isPathWithin(filePath, asset.sourcePath)) {
182+
return asset.kind;
183+
}
184+
}
185+
return "state";
186+
}
187+
134188
async function publishTempArchive(params: {
135189
tempArchivePath: string;
136190
outputPath: string;
@@ -342,22 +396,30 @@ export async function createBackupArchive(
342396
});
343397
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
344398

345-
await tar.c(
399+
const skippedPaths = await createTarArchiveWithMissingSkip(
346400
{
347401
file: tempArchivePath,
348402
gzip: true,
349403
portable: true,
350404
preservePaths: true,
351405
onWriteEntry: (entry) => {
352-
entry.path = remapArchiveEntryPath({
353-
entryPath: entry.path,
406+
(entry as { path: string }).path = remapArchiveEntryPath({
407+
entryPath: (entry as { path: string }).path,
354408
manifestPath,
355409
archiveRoot,
356410
});
357411
},
358412
},
359413
[manifestPath, ...result.assets.map((asset) => asset.sourcePath)],
360414
);
415+
for (const skippedPath of skippedPaths) {
416+
result.skipped.push({
417+
kind: resolveAssetKindForPath(skippedPath, result.assets),
418+
sourcePath: skippedPath,
419+
displayPath: shortenHomePath(skippedPath),
420+
reason: "cleaned up during backup",
421+
});
422+
}
361423
await publishTempArchive({ tempArchivePath, outputPath });
362424
} finally {
363425
await fs.rm(tempArchivePath, { force: true }).catch(() => undefined);

0 commit comments

Comments
 (0)