Skip to content

Commit 300d72b

Browse files
committed
fix(backup): guard stale artifact sweep owners
1 parent 3067b25 commit 300d72b

2 files changed

Lines changed: 163 additions & 10 deletions

File tree

src/infra/backup-create.test.ts

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ import {
2424
} from "./backup-create.js";
2525
import { requireNodeSqlite } from "./node-sqlite.js";
2626

27+
const BACKUP_STAGING_OWNER_FILE = ".openclaw-backup-owner.json";
28+
const BACKUP_ARCHIVE_OWNER_SUFFIX = ".openclaw-owner.json";
29+
2730
function makeResult(overrides: Partial<BackupCreateResult> = {}): BackupCreateResult {
2831
return {
2932
createdAt: "2026-01-01T00:00:00.000Z",
@@ -1226,12 +1229,20 @@ describe("sweepStaleBackupArtifacts", () => {
12261229
await fs.rm(testRoot, { recursive: true, force: true }).catch(() => undefined);
12271230
});
12281231

1232+
async function markOld(targetPath: string): Promise<void> {
1233+
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
1234+
await fs.utimes(targetPath, new Date(twoHoursAgo), new Date(twoHoursAgo));
1235+
}
1236+
1237+
async function writeOwner(ownerPath: string, pid: number): Promise<void> {
1238+
await fs.writeFile(ownerPath, `${JSON.stringify({ pid, startedAtMs: Date.now() })}\n`, "utf8");
1239+
}
1240+
12291241
it("removes stale openclaw-backup-* directories from the selected temp root", async () => {
12301242
const staleDir = path.join(tempRoot, "openclaw-backup-abc123");
12311243
await fs.mkdir(staleDir);
12321244
await fs.writeFile(path.join(staleDir, "manifest.json"), "{}");
1233-
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
1234-
await fs.utimes(staleDir, new Date(twoHoursAgo), new Date(twoHoursAgo));
1245+
await markOld(staleDir);
12351246

12361247
const entries = await fs.readdir(tempRoot, { withFileTypes: true });
12371248
expect(entries.some((e) => e.name === "openclaw-backup-abc123")).toBe(true);
@@ -1246,8 +1257,7 @@ describe("sweepStaleBackupArtifacts", () => {
12461257
const uuid = randomUUID();
12471258
const tempFile = path.join(outputDir, `backup.tar.gz.${uuid}.tmp`);
12481259
await fs.writeFile(tempFile, "data");
1249-
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
1250-
await fs.utimes(tempFile, new Date(twoHoursAgo), new Date(twoHoursAgo));
1260+
await markOld(tempFile);
12511261

12521262
await sweepStaleBackupArtifacts({ outputPath, tempRoot });
12531263

@@ -1276,8 +1286,7 @@ describe("sweepStaleBackupArtifacts", () => {
12761286
await fs.writeFile(path.join(outputDir, "unrelated.txt"), "keep me");
12771287
await fs.writeFile(unrelatedArchiveTemp, "keep me");
12781288
await fs.mkdir(path.join(tempRoot, "some-other-dir"));
1279-
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
1280-
await fs.utimes(unrelatedArchiveTemp, new Date(twoHoursAgo), new Date(twoHoursAgo));
1289+
await markOld(unrelatedArchiveTemp);
12811290

12821291
await sweepStaleBackupArtifacts({ outputPath, tempRoot });
12831292

@@ -1286,4 +1295,60 @@ describe("sweepStaleBackupArtifacts", () => {
12861295
);
12871296
expect(await fs.readdir(tempRoot)).toContain("some-other-dir");
12881297
});
1298+
1299+
it("preserves stale artifacts while their owner process is still alive", async () => {
1300+
const staleDir = path.join(tempRoot, "openclaw-backup-active");
1301+
await fs.mkdir(staleDir);
1302+
await fs.writeFile(path.join(staleDir, "manifest.json"), "{}");
1303+
await writeOwner(path.join(staleDir, BACKUP_STAGING_OWNER_FILE), process.pid);
1304+
await markOld(staleDir);
1305+
1306+
const uuid = randomUUID();
1307+
const tempFileName = `backup.tar.gz.${uuid}.tmp`;
1308+
const tempFile = path.join(outputDir, tempFileName);
1309+
await fs.writeFile(tempFile, "data");
1310+
await writeOwner(`${tempFile}${BACKUP_ARCHIVE_OWNER_SUFFIX}`, process.pid);
1311+
await markOld(tempFile);
1312+
1313+
await sweepStaleBackupArtifacts({ outputPath, tempRoot });
1314+
1315+
expect(await fs.readdir(tempRoot)).toContain("openclaw-backup-active");
1316+
expect(await fs.readdir(outputDir)).toEqual(
1317+
expect.arrayContaining([tempFileName, `${tempFileName}${BACKUP_ARCHIVE_OWNER_SUFFIX}`]),
1318+
);
1319+
});
1320+
1321+
it("removes stale artifacts and owner markers when their owner is not live", async () => {
1322+
const staleDir = path.join(tempRoot, "openclaw-backup-dead-owner");
1323+
await fs.mkdir(staleDir);
1324+
await writeOwner(path.join(staleDir, BACKUP_STAGING_OWNER_FILE), -1);
1325+
await markOld(staleDir);
1326+
1327+
const uuid = randomUUID();
1328+
const tempFileName = `backup.tar.gz.${uuid}.tmp`;
1329+
const tempFile = path.join(outputDir, tempFileName);
1330+
const ownerFileName = `${tempFileName}${BACKUP_ARCHIVE_OWNER_SUFFIX}`;
1331+
await fs.writeFile(tempFile, "data");
1332+
await writeOwner(path.join(outputDir, ownerFileName), -1);
1333+
await markOld(tempFile);
1334+
1335+
await sweepStaleBackupArtifacts({ outputPath, tempRoot });
1336+
1337+
expect(await fs.readdir(tempRoot)).not.toContain("openclaw-backup-dead-owner");
1338+
expect(await fs.readdir(outputDir)).not.toEqual(
1339+
expect.arrayContaining([tempFileName, ownerFileName]),
1340+
);
1341+
});
1342+
1343+
it("preserves stale owner sidecars without archive files when their owner is still alive", async () => {
1344+
const uuid = randomUUID();
1345+
const ownerFileName = `backup.tar.gz.${uuid}.tmp${BACKUP_ARCHIVE_OWNER_SUFFIX}`;
1346+
const ownerPath = path.join(outputDir, ownerFileName);
1347+
await writeOwner(ownerPath, process.pid);
1348+
await markOld(ownerPath);
1349+
1350+
await sweepStaleBackupArtifacts({ outputPath, tempRoot });
1351+
1352+
expect(await fs.readdir(outputDir)).toContain(ownerFileName);
1353+
});
12891354
});

src/infra/backup-create.ts

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -691,9 +691,16 @@ async function createStateSqliteBackupPlan(params: {
691691
}
692692

693693
const STALE_SWEEP_GRACE_MS = 60 * 60 * 1000;
694+
const BACKUP_STAGING_OWNER_FILE = ".openclaw-backup-owner.json";
695+
const BACKUP_ARCHIVE_OWNER_SUFFIX = ".openclaw-owner.json";
694696

695697
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
696698

699+
type BackupArtifactOwner = {
700+
pid: number;
701+
startedAtMs: number;
702+
};
703+
697704
export async function sweepStaleBackupArtifacts(params: {
698705
outputPath: string;
699706
tempRoot: string;
@@ -715,7 +722,10 @@ async function sweepStaleBackupStagingDirs(tempRoot: string): Promise<void> {
715722
await Promise.all(
716723
entries
717724
.filter((entry) => entry.isDirectory() && entry.name.startsWith("openclaw-backup-"))
718-
.map((entry) => removeStalePath(path.join(tempRoot, entry.name))),
725+
.map((entry) => {
726+
const artifactPath = path.join(tempRoot, entry.name);
727+
return removeStalePath(artifactPath, path.join(artifactPath, BACKUP_STAGING_OWNER_FILE));
728+
}),
719729
);
720730
}
721731

@@ -729,14 +739,32 @@ async function sweepStaleBackupArchiveTemps(outputPath: string): Promise<void> {
729739
return;
730740
}
731741

742+
const entryNames = new Set(entries.map((entry) => entry.name));
732743
await Promise.all(
733744
entries
734-
.filter((entry) => entry.isFile() && isBackupArchiveTempName(entry.name, outputBaseName))
735-
.map((entry) => removeStalePath(path.join(outputDir, entry.name))),
745+
.filter((entry) => entry.isFile())
746+
.flatMap((entry) => {
747+
if (isBackupArchiveTempName(entry.name, outputBaseName)) {
748+
const artifactPath = path.join(outputDir, entry.name);
749+
return [removeStalePath(artifactPath, resolveBackupArchiveOwnerPath(artifactPath))];
750+
}
751+
const archiveTempName = resolveBackupArchiveTempNameFromOwnerName(
752+
entry.name,
753+
outputBaseName,
754+
);
755+
if (!archiveTempName || entryNames.has(archiveTempName)) {
756+
return [];
757+
}
758+
const ownerPath = path.join(outputDir, entry.name);
759+
return [removeStalePath(ownerPath, ownerPath)];
760+
}),
736761
);
737762
}
738763

739-
async function removeStalePath(targetPath: string): Promise<void> {
764+
async function removeStalePath(targetPath: string, ownerPath?: string): Promise<void> {
765+
if (ownerPath && (await hasLiveBackupArtifactOwner(ownerPath))) {
766+
return;
767+
}
740768
let stat: import("node:fs").Stats;
741769
try {
742770
stat = await fs.stat(targetPath);
@@ -747,6 +775,9 @@ async function removeStalePath(targetPath: string): Promise<void> {
747775
return;
748776
}
749777
await fs.rm(targetPath, { recursive: true, force: true }).catch(() => undefined);
778+
if (ownerPath && ownerPath !== targetPath) {
779+
await fs.rm(ownerPath, { force: true }).catch(() => undefined);
780+
}
750781
}
751782

752783
function isBackupArchiveTempName(name: string, outputBaseName: string): boolean {
@@ -758,6 +789,58 @@ function isBackupArchiveTempName(name: string, outputBaseName: string): boolean
758789
return UUID_RE.test(name.slice(prefix.length, -suffix.length));
759790
}
760791

792+
function resolveBackupArchiveOwnerPath(tempArchivePath: string): string {
793+
return `${tempArchivePath}${BACKUP_ARCHIVE_OWNER_SUFFIX}`;
794+
}
795+
796+
function resolveBackupArchiveTempNameFromOwnerName(
797+
name: string,
798+
outputBaseName: string,
799+
): string | undefined {
800+
if (!name.endsWith(BACKUP_ARCHIVE_OWNER_SUFFIX)) {
801+
return undefined;
802+
}
803+
const archiveTempName = name.slice(0, -BACKUP_ARCHIVE_OWNER_SUFFIX.length);
804+
return isBackupArchiveTempName(archiveTempName, outputBaseName) ? archiveTempName : undefined;
805+
}
806+
807+
function buildBackupArtifactOwner(): BackupArtifactOwner {
808+
return { pid: process.pid, startedAtMs: Date.now() };
809+
}
810+
811+
async function writeBackupArtifactOwner(
812+
ownerPath: string,
813+
owner: BackupArtifactOwner,
814+
): Promise<void> {
815+
await fs.writeFile(ownerPath, `${JSON.stringify(owner)}\n`, "utf8");
816+
}
817+
818+
async function hasLiveBackupArtifactOwner(ownerPath: string): Promise<boolean> {
819+
let owner: BackupArtifactOwner;
820+
try {
821+
owner = JSON.parse(await fs.readFile(ownerPath, "utf8")) as BackupArtifactOwner;
822+
} catch {
823+
return false;
824+
}
825+
return isLiveBackupOwner(owner);
826+
}
827+
828+
function isLiveBackupOwner(owner: BackupArtifactOwner): boolean {
829+
if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0) {
830+
return false;
831+
}
832+
try {
833+
process.kill(owner.pid, 0);
834+
return true;
835+
} catch (err) {
836+
const code = (err as NodeJS.ErrnoException).code;
837+
if (code === "ESRCH") {
838+
return false;
839+
}
840+
return code === "EPERM";
841+
}
842+
}
843+
761844
export async function createBackupArchive(
762845
opts: BackupCreateOptions = {},
763846
): Promise<BackupCreateResult> {
@@ -820,8 +903,12 @@ export async function createBackupArchive(
820903
const tempDir = await fs.mkdtemp(path.join(tempRoot, "openclaw-backup-"));
821904
const manifestPath = path.join(tempDir, "manifest.json");
822905
const tempArchivePath = buildTempArchivePath(outputPath);
906+
const tempArchiveOwnerPath = resolveBackupArchiveOwnerPath(tempArchivePath);
823907
const stateAsset = result.assets.find((asset) => asset.kind === "state");
824908
try {
909+
const backupOwner = buildBackupArtifactOwner();
910+
await writeBackupArtifactOwner(path.join(tempDir, BACKUP_STAGING_OWNER_FILE), backupOwner);
911+
await writeBackupArtifactOwner(tempArchiveOwnerPath, backupOwner);
825912
const stateSqliteBackup = stateAsset
826913
? await createStateSqliteBackupPlan({
827914
stateDir: stateAsset.sourcePath,
@@ -948,6 +1035,7 @@ export async function createBackupArchive(
9481035
await publishTempArchive({ tempArchivePath, outputPath });
9491036
} finally {
9501037
await fs.rm(tempArchivePath, { force: true }).catch(() => undefined);
1038+
await fs.rm(tempArchiveOwnerPath, { force: true }).catch(() => undefined);
9511039
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
9521040
}
9531041

0 commit comments

Comments
 (0)