diff --git a/loopx/control_plane/coordination/sqlite_backup.ts b/loopx/control_plane/coordination/sqlite_backup.ts index 79462655b0..22cd48f280 100644 --- a/loopx/control_plane/coordination/sqlite_backup.ts +++ b/loopx/control_plane/coordination/sqlite_backup.ts @@ -28,7 +28,9 @@ export async function snapshotSqliteDatabase(source: string, destination: string throw new Error("SQLite backup integrity check failed"); } } finally { copy.close(); } - const handle = await open(destination, "r"); + // Windows flush (FlushFileBuffers) requires write access; the snapshot bytes + // are already complete, so update mode only permits the durability sync. + const handle = await open(destination, "r+"); try { await handle.sync(); } finally { await handle.close(); } return info; } catch (error) { diff --git a/loopx/state_backup.py b/loopx/state_backup.py index 0365494e41..d35f6dab38 100644 --- a/loopx/state_backup.py +++ b/loopx/state_backup.py @@ -619,7 +619,9 @@ def execute_state_backup_plan(payload: dict[str, Any]) -> dict[str, Any]: staged_manifest.write_text(json.dumps(updated, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") for path in (staged_archive, staged_manifest): path.chmod(0o600) - with path.open("rb") as handle: + # Windows requires write access for fsync; update mode only permits + # the durability flush. + with path.open("rb+") as handle: os.fsync(handle.fileno()) os.replace(staged_archive, archive_path) os.replace(staged_manifest, manifest_path) diff --git a/tests/control_plane_ts/sqlite_backup.test.ts b/tests/control_plane_ts/sqlite_backup.test.ts index c5aa55ba7d..bf8e1c26e0 100644 --- a/tests/control_plane_ts/sqlite_backup.test.ts +++ b/tests/control_plane_ts/sqlite_backup.test.ts @@ -34,7 +34,8 @@ test("online backup preserves committed metadata, schema and blobs, excluding an } finally { copy.close(); } writer.exec("ROLLBACK"); assert.equal(writer.prepare("SELECT metadata FROM facts").get()?.metadata, metadata); - assert.equal((await stat(target)).mode & 0o777, 0o600); + // Windows stat reports 0o666 for every writable file and has no owner-only mode. + if (process.platform !== "win32") assert.equal((await stat(target)).mode & 0o777, 0o600); } finally { writer.close(); } }); diff --git a/tests/test_state_backup.py b/tests/test_state_backup.py index 8908d95e97..d45741816f 100644 --- a/tests/test_state_backup.py +++ b/tests/test_state_backup.py @@ -106,8 +106,10 @@ def test_duplicate_targets_reuse_snapshot_and_preserve_ordinary_files(tmp_path): assert (restored / "runtime-root/database-link").is_symlink() assert "runtime-root/notes-wal" in names assert (restored / "runtime-root/not-a-database.sqlite").read_text() == "ordinary text" - assert (Path(payload["archive_path"]).stat().st_mode & 0o777) == 0o600 - assert (Path(payload["manifest_path"]).stat().st_mode & 0o777) == 0o600 + if sys.platform != "win32": + # Windows stat reports 0o666 for every writable file and has no owner-only mode. + assert (Path(payload["archive_path"]).stat().st_mode & 0o777) == 0o600 + assert (Path(payload["manifest_path"]).stat().st_mode & 0o777) == 0o600 def test_backup_does_not_walk_or_archive_junction_targets(tmp_path, monkeypatch):