From 1655799aca03de5c7f4e31ec5bc1f70dc661e4b1 Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Mon, 12 Oct 2026 01:35:32 +0800 Subject: [PATCH 1/2] fix(backup): flush state backup archives through writable handles on Windows On native Windows `loopx backup-state --execute` cannot complete. The SQLite snapshot owner flushes the freshly written copy through a read-only handle (`open(destination, "r")` + `sync()`), and Windows flushes require write access, so every state tree that contains a SQLite file aborts with `EPERM -4048 fsync`. Without SQLite in scope the archive owner then reopens the staged archive and manifest read-only (`path.open("rb")`) before `os.fsync`, which Windows rejects with `EBADF Errno 9`. The user sees only "Effect runtime filesystem operation failed" or "[Errno 9] Bad file descriptor", and the recommended pre-reconnect state backup never happens. Both flushes are deliberate durability steps, so keep them and open the handles for update (`"r+"` / `"rb+"`) on every platform instead of dropping the flush on Windows. The SQLite snapshot regression case also asserted the POSIX owner permission bit, which Windows stat reports as 0o666 for every writable file, so that line is skipped where the host has no such mode. Signed-off-by: JasonBuildAI --- loopx/control_plane/coordination/sqlite_backup.ts | 4 +++- loopx/state_backup.py | 4 +++- tests/control_plane_ts/sqlite_backup.test.ts | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) 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(); } }); From 327c0a756510b9140f695a19eb310327b7ded436 Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Mon, 12 Oct 2026 01:55:14 +0800 Subject: [PATCH 2/2] test(backup): guard the POSIX owner-mode assertion on Windows `test_duplicate_targets_reuse_snapshot_and_preserve_ordinary_files` asserts the staged archive and manifest report mode 0o600, which Windows stat cannot express: it reports 0o666 for every writable file. The case only reaches that line on Windows after the durability flush fix, so a host with symlink privilege would have failed there instead of at the pre-existing symlink setup. Guard the assertion the same way as its TypeScript counterpart. Signed-off-by: JasonBuildAI --- tests/test_state_backup.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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):