mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(sessions): fsync archives through writable handles (#110191)
Fixes #110152.
This commit is contained in:
+1
-1
@@ -1987,7 +1987,7 @@
|
||||
"test:unit:fast:audit": "node scripts/test-unit-fast-audit.mjs",
|
||||
"test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs",
|
||||
"test:watch": "node scripts/test-projects.mjs --watch",
|
||||
"test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/agents/sessions/windows-git-bash-path.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/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
|
||||
"test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/config/sessions/store.session-lifecycle-mutation.test.ts src/agents/sessions/windows-git-bash-path.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/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
|
||||
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
|
||||
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",
|
||||
"ts-topology": "node --import tsx scripts/ts-topology.ts",
|
||||
|
||||
@@ -49,9 +49,9 @@ const ANDROID_NATIVE_RE = /^(apps\/android\/|apps\/shared\/)/;
|
||||
const NODE_SCOPE_RE =
|
||||
/^(src\/|test\/|extensions\/|packages\/|scripts\/|ui\/|\.github\/|openclaw\.mjs$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig.*\.json$|vitest.*\.ts$|tsdown\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.jsonc$)/;
|
||||
const WINDOWS_SCOPE_RE =
|
||||
/^(src\/process\/|src\/infra\/windows-install-roots\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|scripts\/(?:install\.ps1|openclaw-cross-os-release-checks\.ts|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js)|lib\/(?:format-generated-module\.mjs|cross-os-release-checks\/[^/]+\.ts))$|test\/scripts\/(?:format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/;
|
||||
/^(src\/config\/sessions\/(?:session-accessor\.sqlite-archive|store\.session-lifecycle-mutation\.test)\.ts$|src\/process\/|src\/infra\/windows-install-roots\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|scripts\/(?:install\.ps1|openclaw-cross-os-release-checks\.ts|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js)|lib\/(?:format-generated-module\.mjs|cross-os-release-checks\/[^/]+\.ts))$|test\/scripts\/(?:format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/;
|
||||
const WINDOWS_TEST_SCOPE_RE =
|
||||
/^(src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/windows-install-roots\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|test\/scripts\/(?:format-generated-module|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/;
|
||||
/^(src\/config\/sessions\/store\.session-lifecycle-mutation\.test\.ts$|src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/windows-install-roots\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|test\/scripts\/(?:format-generated-module|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/;
|
||||
const WINDOWS_DAEMON_SCOPE_RE =
|
||||
/^src\/daemon\/(?:schtasks(?:[-.][^/]+)?|runtime-hints\.windows-paths(?:\.test)?|test-helpers\/schtasks-(?:base-mocks|fixtures))\.ts$/;
|
||||
const CONTROL_UI_I18N_SCOPE_RE =
|
||||
|
||||
@@ -102,8 +102,7 @@ function writeSqliteTranscriptArchive(params: {
|
||||
}
|
||||
const tempPath = `${archivePath}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(tempPath, encoded.bytes, { flag: "wx", mode: 0o600 });
|
||||
fsyncRegularFile(tempPath);
|
||||
writeDurableFileExclusive(tempPath, encoded.bytes);
|
||||
fs.renameSync(tempPath, archivePath);
|
||||
fsyncDirectory(params.archiveDirectory);
|
||||
return archivePath;
|
||||
@@ -118,9 +117,12 @@ function writeSqliteTranscriptArchive(params: {
|
||||
throw new Error(`Could not create SQLite transcript archive for ${params.sessionId}`);
|
||||
}
|
||||
|
||||
function fsyncRegularFile(filePath: string): void {
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
// Windows rejects fsync on read-only handles, so keep the exclusive writable
|
||||
// descriptor open through both the write and durability boundary.
|
||||
function writeDurableFileExclusive(filePath: string, content: Buffer): void {
|
||||
const fd = fs.openSync(filePath, "wx", 0o600);
|
||||
try {
|
||||
fs.writeFileSync(fd, content);
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
|
||||
@@ -319,7 +319,7 @@ describe("session store lifecycle mutations", () => {
|
||||
expect(readSearchState()).toEqual({ fts: [], watermarks: [] });
|
||||
});
|
||||
|
||||
it("durably writes SQLite transcript archives before deleting entry rows", async () => {
|
||||
it("fsyncs SQLite transcript archives through their writable descriptor before deletion", async () => {
|
||||
const now = Date.now();
|
||||
await replaceSessionEntry(
|
||||
{ sessionKey: "agent:main:durable-delete", storePath },
|
||||
@@ -333,17 +333,19 @@ describe("session store lifecycle mutations", () => {
|
||||
[createTranscriptEvent("durable-delete-session", "durable archive first")],
|
||||
);
|
||||
|
||||
const originalWriteFileSync = fs.writeFileSync;
|
||||
const entryObservedDuringArchiveWrite: boolean[] = [];
|
||||
const writeSpy = vi.spyOn(fs, "writeFileSync").mockImplementation((...args) => {
|
||||
const filePath = String(args[0]);
|
||||
if (filePath.includes("durable-delete-session.jsonl.deleted.")) {
|
||||
entryObservedDuringArchiveWrite.push(
|
||||
const originalRenameSync = fs.renameSync;
|
||||
const entryObservedDuringArchiveRename: boolean[] = [];
|
||||
const openSpy = vi.spyOn(fs, "openSync");
|
||||
const fsyncSpy = vi.spyOn(fs, "fsyncSync");
|
||||
const renameSpy = vi.spyOn(fs, "renameSync").mockImplementation((...args) => {
|
||||
const archivePath = String(args[1]);
|
||||
if (archivePath.includes("durable-delete-session.jsonl.deleted.")) {
|
||||
entryObservedDuringArchiveRename.push(
|
||||
loadSessionEntry({ sessionKey: "agent:main:durable-delete", storePath })?.sessionId ===
|
||||
"durable-delete-session",
|
||||
);
|
||||
}
|
||||
return originalWriteFileSync(...args);
|
||||
return originalRenameSync(...args);
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -358,9 +360,18 @@ describe("session store lifecycle mutations", () => {
|
||||
|
||||
expect(result.deleted).toBe(true);
|
||||
expect(result.archivedTranscripts).toHaveLength(1);
|
||||
expect(entryObservedDuringArchiveWrite).toEqual([true]);
|
||||
expect(entryObservedDuringArchiveRename).toEqual([true]);
|
||||
const archiveTempOpenIndexes = openSpy.mock.calls.flatMap((args, index) =>
|
||||
String(args[0]).includes("durable-delete-session.jsonl.deleted.") ? [index] : [],
|
||||
);
|
||||
expect(archiveTempOpenIndexes).toHaveLength(1);
|
||||
const archiveTempOpenIndex = archiveTempOpenIndexes[0] ?? -1;
|
||||
expect(openSpy.mock.calls[archiveTempOpenIndex]?.[1]).toBe("wx");
|
||||
expect(fsyncSpy).toHaveBeenCalledWith(openSpy.mock.results[archiveTempOpenIndex]?.value);
|
||||
} finally {
|
||||
writeSpy.mockRestore();
|
||||
renameSpy.mockRestore();
|
||||
fsyncSpy.mockRestore();
|
||||
openSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Windows CI scope tests cover paths with platform-specific runtime contracts.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { detectChangedScope } = await import("../../scripts/ci-changed-scope.mjs");
|
||||
|
||||
describe("detectChangedScope Windows routing", () => {
|
||||
it("routes SQLite transcript archive changes to Windows", () => {
|
||||
for (const archivePath of [
|
||||
"src/config/sessions/session-accessor.sqlite-archive.ts",
|
||||
"src/config/sessions/store.session-lifecycle-mutation.test.ts",
|
||||
]) {
|
||||
expect(detectChangedScope([archivePath]), archivePath).toMatchObject({
|
||||
runNode: true,
|
||||
runWindows: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -169,6 +169,12 @@ describe("package scripts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("runs SQLite transcript archive durability coverage in Windows CI", () => {
|
||||
expect(readPackageJson().scripts["test:windows:ci"]).toContain(
|
||||
"src/config/sessions/store.session-lifecycle-mutation.test.ts",
|
||||
);
|
||||
});
|
||||
|
||||
it("runs cross-OS installer behavior coverage in Windows CI", () => {
|
||||
expect(readPackageJson().scripts["test:windows:ci"]).toContain(
|
||||
"test/scripts/openclaw-cross-os-installer.windows.test.ts",
|
||||
|
||||
Reference in New Issue
Block a user