Skip to content

Commit 13c1d3c

Browse files
steipeteZOOWH
andauthored
fix(backup): close archive stream before retry cleanup (#101464)
* fix(backup): close archive stream before retry cleanup Own the tar source and file writer with stream.pipeline so live-file EOF failures close the partial archive before Windows cleanup. Keep one native Windows handle-release regression and focused CI coverage. Fixes #101382 Co-authored-by: 徐闻涵0668001344 <[email protected]> * test(backup): track archive retry temp directory * test(backup): model tar archive streams * test(backup): model retry cleanup streams --------- Co-authored-by: 徐闻涵0668001344 <[email protected]>
1 parent 2e4e982 commit 13c1d3c

7 files changed

Lines changed: 128 additions & 70 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
2727
- **Codex app-server protocol:** require app-server 0.142 or newer, remove pre-0.142 wire-shape compatibility, and teach Codex to retrieve deferred native `spawn_agent` through `tool_search` so native subagent task mirroring works on search-capable models. (#101221)
2828
- **Android hardware keyboard chat:** send with unmodified Enter on physical keyboards while preserving Shift+Enter and other modified Enter combinations for multiline input. (#101239) Thanks @3ninyt3nin-creator.
2929
- **CJK Markdown emphasis:** render adjacent Chinese, Japanese, and Korean emphasis punctuation through the shared Markdown pipeline instead of leaking literal markers across channels. (#101230, #101120) Thanks @nicknmorty.
30+
- **Backup retry cleanup:** close partial archive output handles and isolate each retry path after live-write failures, preventing Windows `EBUSY` locks from cascading across attempts or leaving stale temp archives. (#101397, #101449) Thanks @ZOOWH and @LiLan0125.
3031
- **Codex yielded native subagents:** keep the parent app-server subscription and shared client alive until yielded native subagent completion delivery settles, preventing lost wakeups and leaked one-shot cleanup.
3132
- **Delivery recovery pacing:** pace eligible outbound and restart-continuation replays after gateway startup so outage backlogs do not burst into channel rate limits, while preserving the wall-clock recovery budget. (#101118, #101058) Thanks @ZengWen-DT.
3233
- **Outbound pre-connect recovery:** clear stale platform-send evidence atomically when a connect or DNS failure proves no request was sent, allowing queued Discord and other channel messages to replay after connectivity returns without weakening the unknown-send duplicate guard. (#101024, #100979) Thanks @SunnyShu0925.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1942,7 +1942,7 @@
19421942
"test:unit:fast:audit": "node scripts/test-unit-fast-audit.mjs",
19431943
"test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs",
19441944
"test:watch": "node scripts/test-projects.mjs --watch",
1945-
"test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
1945+
"test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/backup-create.windows.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
19461946
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
19471947
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",
19481948
"ts-topology": "node --import tsx scripts/ts-topology.ts",

src/commands/backup.atomic.test.ts

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
66
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
77
import {
88
backupVerifyCommandMock,
9+
createMockTarStream,
910
createBackupTestRuntime,
1011
mockStateOnlyBackupPlan,
1112
resetBackupTempHome,
@@ -70,7 +71,7 @@ describe("backupCreateCommand atomic archive write", () => {
7071
archivePrefix: "openclaw-backup-failure-",
7172
});
7273
try {
73-
tarCreateMock.mockRejectedValueOnce(new Error("disk full"));
74+
tarCreateMock.mockReturnValueOnce(createMockTarStream({ error: new Error("disk full") }));
7475

7576
await expect(
7677
backupCreateCommand(runtime, {
@@ -100,22 +101,28 @@ describe("backupCreateCommand atomic archive write", () => {
100101
const key = String(targetPath);
101102
const attempt = (rmAttempts.get(key) ?? 0) + 1;
102103
rmAttempts.set(key, attempt);
103-
if ((key === attemptFiles[0] || key === attemptFiles[1]) && attempt === 1) {
104+
if (key.startsWith(`${outputPath}.`) && !attemptFiles.includes(key)) {
105+
attemptFiles.push(key);
106+
}
107+
if (attemptFiles.length <= 2 && key === attemptFiles.at(-1) && attempt === 1) {
104108
throw Object.assign(new Error("resource busy"), { code: "EBUSY" });
105109
}
106110
await realRm(targetPath, options);
107111
}) as typeof fs.rm);
108112
try {
109113
let tarAttempt = 0;
110-
tarCreateMock.mockImplementation(async ({ file }: { file: string }) => {
114+
tarCreateMock.mockImplementation(() => {
111115
tarAttempt += 1;
112-
attemptFiles.push(file);
113-
await fs.writeFile(file, `archive-attempt-${tarAttempt}`, "utf8");
114-
if (tarAttempt < 3) {
115-
throw Object.assign(new Error("did not encounter expected EOF"), {
116-
path: path.join(tempHome.home, ".openclaw", "state.txt"),
117-
});
118-
}
116+
return createMockTarStream({
117+
contents: `archive-attempt-${tarAttempt}`,
118+
...(tarAttempt < 3
119+
? {
120+
error: Object.assign(new Error("did not encounter expected EOF"), {
121+
path: path.join(tempHome.home, ".openclaw", "state.txt"),
122+
}),
123+
}
124+
: {}),
125+
});
119126
});
120127

121128
const result = await backupCreateCommand(runtime, {
@@ -143,9 +150,7 @@ describe("backupCreateCommand atomic archive write", () => {
143150
const realLink = fs.link.bind(fs);
144151
const linkSpy = vi.spyOn(fs, "link");
145152
try {
146-
tarCreateMock.mockImplementationOnce(async ({ file }: { file: string }) => {
147-
await fs.writeFile(file, "archive-bytes", "utf8");
148-
});
153+
tarCreateMock.mockReturnValueOnce(createMockTarStream());
149154
linkSpy.mockImplementationOnce(async (existingPath, newPath) => {
150155
await fs.writeFile(newPath, "concurrent-archive", "utf8");
151156
return await realLink(existingPath, newPath);
@@ -170,9 +175,7 @@ describe("backupCreateCommand atomic archive write", () => {
170175
});
171176
const linkSpy = vi.spyOn(fs, "link");
172177
try {
173-
tarCreateMock.mockImplementationOnce(async ({ file }: { file: string }) => {
174-
await fs.writeFile(file, "archive-bytes", "utf8");
175-
});
178+
tarCreateMock.mockReturnValueOnce(createMockTarStream());
176179
linkSpy.mockRejectedValueOnce(
177180
Object.assign(new Error("hard links not supported"), { code: "EOPNOTSUPP" }),
178181
);

src/commands/backup.test-support.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Backup test support provides temp config/state fixtures and mocked backup runtime helpers.
22
import fs from "node:fs/promises";
33
import path from "node:path";
4+
import { Readable } from "node:stream";
45
import { vi } from "vitest";
56
import type { RuntimeEnv } from "../runtime.js";
67
import { deleteTestEnvValue } from "../test-utils/env.js";
@@ -14,6 +15,24 @@ const backupTestMocks = vi.hoisted(() => ({
1415

1516
export const { backupVerifyCommandMock, tarCreateMock } = backupTestMocks;
1617

18+
export function createMockTarStream(
19+
params: {
20+
beforeRead?: () => Promise<void> | void;
21+
contents?: string;
22+
error?: Error;
23+
} = {},
24+
): Readable {
25+
return Readable.from(
26+
(async function* () {
27+
await params.beforeRead?.();
28+
if (params.error) {
29+
throw params.error;
30+
}
31+
yield params.contents ?? "archive-bytes";
32+
})(),
33+
);
34+
}
35+
1736
vi.mock("tar", () => ({
1837
c: backupTestMocks.tarCreateMock,
1938
}));

src/commands/backup.test.ts

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "./backup-shared.js";
1818
import {
1919
backupVerifyCommandMock,
20+
createMockTarStream,
2021
createBackupTestRuntime,
2122
mockStateOnlyBackupPlan,
2223
resetBackupTempHome,
@@ -78,9 +79,7 @@ describe("backup commands", () => {
7879
beforeEach(async () => {
7980
await resetBackupTempHome(tempHome);
8081
tarCreateMock.mockReset();
81-
tarCreateMock.mockImplementation(async ({ file }: { file: string }) => {
82-
await fs.writeFile(file, "archive-bytes", "utf8");
83-
});
82+
tarCreateMock.mockImplementation(() => createMockTarStream());
8483
backupVerifyCommandMock.mockReset();
8584
backupVerifyCommandMock.mockResolvedValue({
8685
ok: true,
@@ -268,17 +267,16 @@ describe("backup commands", () => {
268267
}),
269268
);
270269
tarCreateMock.mockImplementationOnce(
271-
async (
272-
options: { file: string; onWriteEntry?: (entry: { path: string }) => void },
273-
entryPaths: string[],
274-
) => {
275-
capturedManifest = JSON.parse(
276-
await fs.readFile(entryPaths[0], "utf8"),
277-
) as CapturedBackupManifest;
278-
capturedEntryPaths = entryPaths;
279-
capturedOnWriteEntry = options.onWriteEntry ?? null;
280-
await fs.writeFile(options.file, "archive-bytes", "utf8");
281-
},
270+
(options: { onWriteEntry?: (entry: { path: string }) => void }, entryPaths: string[]) =>
271+
createMockTarStream({
272+
beforeRead: async () => {
273+
capturedManifest = JSON.parse(
274+
await fs.readFile(entryPaths[0], "utf8"),
275+
) as CapturedBackupManifest;
276+
capturedEntryPaths = entryPaths;
277+
capturedOnWriteEntry = options.onWriteEntry ?? null;
278+
},
279+
}),
282280
);
283281
const result = await backupCreateCommand(runtime, {
284282
output: backupDir,
@@ -367,21 +365,20 @@ describe("backup commands", () => {
367365
const runtime = createBackupTestRuntime();
368366
await mockStateOnlyBackupPlan(stateDir);
369367
tarCreateMock.mockImplementationOnce(
370-
async (
371-
options: { file: string; filter?: (entryPath: string) => boolean },
372-
entryPaths: string[],
373-
) => {
374-
const manifestPath = entryPaths[0];
375-
const stateRoot = entryPaths[1];
376-
if (!manifestPath || !stateRoot) {
377-
throw new Error("backup test expected manifest and state entries");
378-
}
379-
expect(options.filter?.(manifestPath)).toBe(true);
380-
expect(
381-
options.filter?.(path.join(stateRoot, "agents", "main", "sessions", "s.jsonl")),
382-
).toBe(false);
383-
await fs.writeFile(options.file, "archive-bytes", "utf8");
384-
},
368+
(options: { filter?: (entryPath: string) => boolean }, entryPaths: string[]) =>
369+
createMockTarStream({
370+
beforeRead: () => {
371+
const manifestPath = entryPaths[0];
372+
const stateRoot = entryPaths[1];
373+
if (!manifestPath || !stateRoot) {
374+
throw new Error("backup test expected manifest and state entries");
375+
}
376+
expect(options.filter?.(manifestPath)).toBe(true);
377+
expect(
378+
options.filter?.(path.join(stateRoot, "agents", "main", "sessions", "s.jsonl")),
379+
).toBe(false);
380+
},
381+
}),
385382
);
386383

387384
const result = await backupCreateCommand(runtime, {

src/infra/backup-create.ts

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
// Creates backup archives while filtering volatile runtime state.
22
import { randomUUID } from "node:crypto";
3-
import { constants as fsConstants, type Stats } from "node:fs";
3+
import { constants as fsConstants, createWriteStream, type Stats } from "node:fs";
44
import fs from "node:fs/promises";
55
import os from "node:os";
66
import path from "node:path";
77
import type { DatabaseSync } from "node:sqlite";
8+
import { pipeline } from "node:stream/promises";
89
import { resolveDateTimestampMs } from "@openclaw/normalization-core/number-coercion";
910
import { loadSqliteVecExtension } from "../../packages/memory-host-sdk/src/engine-storage.js";
1011
import {
@@ -184,6 +185,15 @@ async function removeBackupTempArchiveBestEffort(tempArchivePath: string): Promi
184185
await fs.rm(tempArchivePath, { force: true }).catch(() => undefined);
185186
}
186187

188+
async function writeArchiveStreamToFile(params: {
189+
archivePath: string;
190+
archiveStream: AsyncIterable<Uint8Array> | NodeJS.ReadableStream;
191+
}): Promise<void> {
192+
// Own both stream lifecycles so a tar read error closes the output handle
193+
// before retry cleanup touches the partial archive.
194+
await pipeline(params.archiveStream, createWriteStream(params.archivePath));
195+
}
196+
187197
async function writeTarArchiveWithRetry(params: {
188198
tempArchivePath: string;
189199
runTar: (tempArchivePath: string) => Promise<void>;
@@ -239,6 +249,7 @@ async function writeTarArchiveWithRetry(params: {
239249
}
240250

241251
export const testApi = {
252+
writeArchiveStreamToFile,
242253
writeTarArchiveWithRetry,
243254
isTarEofRaceError,
244255
createBackupVolatileStatCache,
@@ -888,30 +899,32 @@ export async function createBackupArchive(
888899
// cumulative skip counts across attempts instead of the final one.
889900
skippedVolatileCount = 0;
890901
unexpectedSqliteSourcePaths.length = 0;
891-
await tar.c(
892-
{
893-
file: attemptTempArchivePath,
894-
gzip: true,
895-
portable: true,
896-
preservePaths: true,
897-
linkCache: new BackupLinkCache(),
898-
statCache: createBackupVolatileStatCache(volatilePlan),
899-
filter: tarFilter,
900-
onWriteEntry: (entry) => {
901-
entry.path = remapArchiveEntryPath({
902-
entryPath: entry.path,
903-
manifestPath,
904-
archiveRoot,
905-
sourcePathRemaps,
906-
});
902+
await writeArchiveStreamToFile({
903+
archivePath: attemptTempArchivePath,
904+
archiveStream: tar.c(
905+
{
906+
gzip: true,
907+
portable: true,
908+
preservePaths: true,
909+
linkCache: new BackupLinkCache(),
910+
statCache: createBackupVolatileStatCache(volatilePlan),
911+
filter: tarFilter,
912+
onWriteEntry: (entry) => {
913+
entry.path = remapArchiveEntryPath({
914+
entryPath: entry.path,
915+
manifestPath,
916+
archiveRoot,
917+
sourcePathRemaps,
918+
});
919+
},
907920
},
908-
},
909-
[
910-
manifestPath,
911-
...stateSqliteBackup.snapshots.map((snapshot) => snapshot.sourcePath),
912-
...result.assets.map((asset) => asset.sourcePath),
913-
],
914-
);
921+
[
922+
manifestPath,
923+
...stateSqliteBackup.snapshots.map((snapshot) => snapshot.sourcePath),
924+
...result.assets.map((asset) => asset.sourcePath),
925+
],
926+
),
927+
});
915928
const unexpectedSqliteSourcePath = unexpectedSqliteSourcePaths[0];
916929
if (unexpectedSqliteSourcePath) {
917930
throw new Error(
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import fs from "node:fs/promises";
2+
import path from "node:path";
3+
import { PassThrough } from "node:stream";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
6+
import { testApi as backupCreateInternals } from "./backup-create.js";
7+
8+
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
9+
10+
describe("writeArchiveStreamToFile", () => {
11+
it("closes a partial archive before propagating a stream error", async () => {
12+
const tempDir = tempDirs.make("openclaw-backup-stream-");
13+
const archivePath = path.join(tempDir, "partial.tar.gz");
14+
const archiveStream = new PassThrough();
15+
const writePromise = backupCreateInternals.writeArchiveStreamToFile({
16+
archivePath,
17+
archiveStream,
18+
});
19+
archiveStream.write("partial archive");
20+
archiveStream.destroy(new Error("injected tar read failure"));
21+
22+
await expect(writePromise).rejects.toThrow("injected tar read failure");
23+
await expect(fs.rm(archivePath)).resolves.toBeUndefined();
24+
});
25+
});

0 commit comments

Comments
 (0)